chore: adopt the KofTwentyTwo standards #2

Merged
KofTwentyTwo merged 11 commits from chore/adopt-k22-standards into main 2026-10-09 19:43:18 +00:00
110 changed files with 6837 additions and 4267 deletions

No files matched your search

+174 -23
View File
@@ -1,39 +1,141 @@
# AppKit code style. Enforced in every build via Directory.Build.props
# (EnforceCodeStyleInBuild + TreatWarningsAsErrors) and by the CI format gate
# (dotnet format --verify-no-changes --severity error).
# KofTwentyTwo .NET code style (standards: K22-CODE-CS): the Kingsrook code style
# (https://github.com/QRun-IO/qqq/blob/develop/CODE_STYLE.md) applied to C#.
# Copy to the repository root. Severities of "warning" fail the build because the
# companion Directory.Build.props sets TreatWarningsAsErrors and EnforceCodeStyleInBuild,
# so local `dotnet build` and CI enforce exactly the same rules. The resharper_* keys
# apply the Kingsrook blank-line rules in Rider and ReSharper, which .NET's own
# formatter cannot express.
root = true
[*]
charset = utf-8
end_of_line = crlf
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
# Kingsrook: always 3 spaces, never tabs.
indent_size = 3
tab_width = 3
# MSBuild, config, and workflow files use 2-space indentation.
[*.{csproj,props,targets,slnx,xml,json,yml,yaml}]
# YAML stays at 2: the YAML ecosystem (GitHub Actions, Dependabot) assumes it.
[*.{yml,yaml}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
# Windows-only script formats require CRLF.
[*.{cmd,bat}]
end_of_line = crlf
[*.cs]
# System usings first, as the codebase already does (used by IDE "Sort Usings").
#### Formatting (IDE0055 makes every rule below a build error) ####
dotnet_diagnostic.IDE0055.severity = warning
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_between_query_expression_clauses = true
csharp_indent_case_contents = true
csharp_indent_switch_labels = true
csharp_indent_labels = one_less_than_current
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_space_after_cast = false
# Kingsrook: no space between a control keyword and its parenthesis: if(x), for(...), catch(...).
csharp_space_after_keywords_in_control_flow_statements = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_around_binary_operators = before_and_after
csharp_space_after_comma = true
csharp_space_before_comma = false
csharp_space_after_semicolon_in_for_statement = true
csharp_preserve_single_line_statements = false
csharp_preserve_single_line_blocks = true
# Braces on every control-flow block; the codebase is already 100% compliant.
csharp_prefer_braces = true:warning
#### Kingsrook blank lines and layout (Rider / ReSharper; .NET formatter cannot express) ####
resharper_blank_lines_around_method = 3
resharper_blank_lines_around_property = 3
resharper_blank_lines_around_auto_property = 1
resharper_blank_lines_around_type = 3
resharper_blank_lines_after_using_list = 2
resharper_keep_blank_lines_in_code = 1
resharper_keep_blank_lines_in_declarations = 3
resharper_brace_style = next_line
resharper_indent_size = 3
resharper_space_after_keywords_in_control_flow_statements = false
resharper_max_line_length = 999
resharper_wrap_before_first_method_call = true
resharper_wrap_chained_method_calls = chop_if_long
# Every project uses file-scoped namespaces (AppKit has no XAML-generated code).
#### Usings and namespaces ####
# IDE0005 needs GenerateDocumentationFile=true to run during build (set in Directory.Build.props).
dotnet_diagnostic.IDE0005.severity = warning
csharp_using_directive_placement = outside_namespace:warning
csharp_style_namespace_declarations = file_scoped:warning
dotnet_diagnostic.IDE0161.severity = warning
# CA1716 warns that IActivityLog.Error collides with a VB keyword; this is a C#-only
# codebase and Error is the natural name for a log level.
dotnet_diagnostic.CA1716.severity = none
#### var: only when the type is apparent from the right-hand side ####
csharp_style_var_for_built_in_types = false:warning
csharp_style_var_when_type_is_apparent = true:warning
csharp_style_var_elsewhere = false:warning
dotnet_diagnostic.IDE0007.severity = warning
dotnet_diagnostic.IDE0008.severity = warning
# CA1859 asks callers to replace IActivityLog/ISecretVault parameters with concrete types
# for micro-performance; those interfaces are the codebase's deliberate test seams.
dotnet_diagnostic.CA1859.severity = none
#### Braces, modifiers, readonly ####
csharp_prefer_braces = true:warning
dotnet_diagnostic.IDE0011.severity = warning
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
dotnet_diagnostic.IDE0040.severity = warning
dotnet_style_readonly_field = true:warning
dotnet_diagnostic.IDE0044.severity = warning
csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:warning
dotnet_diagnostic.IDE0036.severity = warning
# Naming: interfaces are I-prefixed; types and members are PascalCase.
#### this. qualification: never ####
dotnet_style_qualification_for_field = false:warning
dotnet_style_qualification_for_property = false:warning
dotnet_style_qualification_for_method = false:warning
dotnet_style_qualification_for_event = false:warning
dotnet_diagnostic.IDE0003.severity = warning
#### Language keywords over framework type names ####
dotnet_style_predefined_type_for_locals_parameters_members = true:warning
dotnet_style_predefined_type_for_member_access = true:warning
dotnet_diagnostic.IDE0049.severity = warning
#### Dead code and redundancy ####
dotnet_diagnostic.IDE0051.severity = warning # unused private member
dotnet_diagnostic.IDE0052.severity = warning # unread private member
dotnet_diagnostic.IDE0059.severity = warning # unnecessary assignment
dotnet_diagnostic.IDE0060.severity = warning # unused parameter
dotnet_diagnostic.IDE0035.severity = warning # unreachable code
dotnet_diagnostic.IDE0004.severity = warning # unnecessary cast
dotnet_diagnostic.IDE0100.severity = warning # unnecessary equality operator
dotnet_diagnostic.IDE0080.severity = warning # unnecessary suppression operator
dotnet_diagnostic.IDE0079.severity = warning # unnecessary suppression
dotnet_code_quality_unused_parameters = all
#### Modern language features (suggestions: shown in IDE, not enforced) ####
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
csharp_style_prefer_switch_expression = true:suggestion
csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
csharp_style_expression_bodied_properties = true:suggestion
#### Naming ####
dotnet_naming_rule.interfaces_begin_with_i.symbols = interfaces
dotnet_naming_rule.interfaces_begin_with_i.style = i_prefix_pascal_case
dotnet_naming_rule.interfaces_begin_with_i.severity = warning
@@ -41,20 +143,69 @@ dotnet_naming_symbols.interfaces.applicable_kinds = interface
dotnet_naming_style.i_prefix_pascal_case.required_prefix = I
dotnet_naming_style.i_prefix_pascal_case.capitalization = pascal_case
dotnet_naming_rule.type_parameters_begin_with_t.symbols = type_parameters
dotnet_naming_rule.type_parameters_begin_with_t.style = t_prefix_pascal_case
dotnet_naming_rule.type_parameters_begin_with_t.severity = warning
dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter
dotnet_naming_style.t_prefix_pascal_case.required_prefix = T
dotnet_naming_style.t_prefix_pascal_case.capitalization = pascal_case
dotnet_naming_rule.types_and_members_are_pascal_case.symbols = types_and_members
dotnet_naming_rule.types_and_members_are_pascal_case.style = pascal_case
dotnet_naming_rule.types_and_members_are_pascal_case.severity = warning
dotnet_naming_symbols.types_and_members.applicable_kinds = class, struct, enum, delegate, property, method, event
dotnet_naming_symbols.types_and_members.applicable_kinds = namespace, class, struct, enum, delegate, property, method, event
dotnet_naming_style.pascal_case.capitalization = pascal_case
[tests/**.cs]
# CA1707: xunit's Method_Scenario_Expectation naming uses underscores by convention.
dotnet_naming_rule.private_fields_are_underscore_camel.symbols = private_fields
dotnet_naming_rule.private_fields_are_underscore_camel.style = underscore_camel_case
dotnet_naming_rule.private_fields_are_underscore_camel.severity = warning
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, private_protected
dotnet_naming_symbols.private_fields.required_modifiers =
dotnet_naming_style.underscore_camel_case.required_prefix = _
dotnet_naming_style.underscore_camel_case.capitalization = camel_case
dotnet_naming_rule.private_static_fields_are_s_camel.symbols = private_static_fields
dotnet_naming_rule.private_static_fields_are_s_camel.style = s_camel_case
dotnet_naming_rule.private_static_fields_are_s_camel.severity = warning
dotnet_naming_symbols.private_static_fields.applicable_kinds = field
dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, private_protected
dotnet_naming_symbols.private_static_fields.required_modifiers = static
dotnet_naming_style.s_camel_case.required_prefix = s_
dotnet_naming_style.s_camel_case.capitalization = camel_case
dotnet_naming_rule.constants_are_pascal_case.symbols = constants
dotnet_naming_rule.constants_are_pascal_case.style = pascal_case
dotnet_naming_rule.constants_are_pascal_case.severity = warning
dotnet_naming_symbols.constants.applicable_kinds = field, local
dotnet_naming_symbols.constants.required_modifiers = const
#### Analyzer adjustments (each one justified) ####
# CA1716: flags members named like VB keywords (e.g. ILog.Error). KofTwentyTwo is C#-only.
dotnet_diagnostic.CA1716.severity = none
# MA0004 (ConfigureAwait): app code runs on UI threads where ConfigureAwait(false) is
# wrong by default; libraries opt back in with a [*.cs] override in their own folder.
dotnet_diagnostic.MA0004.severity = none
# MA0049 (type name matches namespace): conflicts with the AppName/AppName.Core layout.
dotnet_diagnostic.MA0049.severity = none
[**/{Generated,obj}/**.cs]
generated_code = true
[{tests,test}/**.cs]
# xUnit's Method_Scenario_Expectation names use underscores by convention.
dotnet_diagnostic.CA1707.severity = none
# CA1861: inline array arguments keep test data at the assertion site; each test runs once,
# so the "repeated allocation" concern does not apply.
# Inline arrays keep test data at the assertion; each test runs once.
dotnet_diagnostic.CA1861.severity = none
# Test projects are not documented API.
dotnet_diagnostic.CS1591.severity = none
# MA0048 (one type per file): a test file groups the test classes and fakes of one feature.
dotnet_diagnostic.MA0048.severity = none
[samples/**.cs]
# CA1822: XAML event handlers and x:Bind targets must be instance members; the XAML
# compiler generates instance calls (this.Handler) for them.
dotnet_diagnostic.CA1822.severity = none
# IDE0060: XAML event handlers must keep the (sender, e) signature the XAML compiler wires
# up, even when a handler uses neither; `dotnet format` cannot see that wiring.
dotnet_diagnostic.IDE0060.severity = none
+6 -4
View File
@@ -1,4 +1,6 @@
# Text files are stored LF and checked out CRLF on every machine, matching
# .editorconfig (end_of_line = crlf) so the dotnet format gate behaves the same
# at developers' desks and on CI runners regardless of their git config.
* text=auto eol=crlf
# Text is stored and checked out as LF on every OS (K22-CODE-07, K22-REPO-03).
* text=auto eol=lf
*.png binary
*.ico binary
*.cmd text eol=crlf
*.bat text eol=crlf
+2 -1
View File
@@ -1,2 +1,3 @@
# AppKit is maintained by a single owner; all changes route to them for review.
# Every path has an owner (K22-REPO-08). Add narrower rules below for areas that
# need a specific reviewer once the project has more than one maintainer.
* @KofTwentyTwo
+16 -23
View File
@@ -1,47 +1,40 @@
name: Bug report
description: Something in an AppKit package is broken
description: Something is broken
labels: [bug]
body:
- type: markdown
attributes:
value: >-
Security problem? Do not file it here; use the private
[vulnerability report](https://github.com/KofTwentyTwo/AppKit/security/advisories/new).
- type: input
id: version
attributes:
label: AppKit version
placeholder: "e.g. 0.1.0"
label: Version
description: The exact version (Help, About, or `--version`).
validations:
required: true
- type: dropdown
id: package
attributes:
label: Package
options:
- KofTwentyTwo.AppKit
- KofTwentyTwo.AppKit.Updates
- KofTwentyTwo.AppKit.WinUI
- KofTwentyTwo.AppKit.Wpf
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened, and what did you expect?
description: Include any exact error message or stack trace.
description: Include exact error messages.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: Ideally against one of the sample apps in `samples/`.
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: Windows version (`winver`), packaged (MSIX) or unpackaged (Velopack), and the host app if relevant.
validations:
required: false
description: OS and version, and anything else relevant.
- type: textarea
id: logs
attributes:
label: Logs
description: Relevant log lines. Remove any tokens, passwords, or personal data first.
render: text
+5 -1
View File
@@ -1 +1,5 @@
blank_issues_enabled: true
blank_issues_enabled: false
contact_links:
- name: Report a security vulnerability
url: https://github.com/KofTwentyTwo/AppKit/security/advisories/new
about: Please report security issues privately, never as a public issue.
+5 -17
View File
@@ -1,32 +1,20 @@
name: Feature request
description: Suggest something every KofTwentyTwo app should get from AppKit
description: Suggest an improvement
labels: [enhancement]
body:
- type: textarea
id: problem
attributes:
label: What problem would this solve?
description: Which apps need it, and what do they do today instead?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed API or behavior
label: Proposed solution
validations:
required: true
- type: dropdown
id: package
- type: textarea
id: alternatives
attributes:
label: Which package?
options:
- KofTwentyTwo.AppKit
- KofTwentyTwo.AppKit.Updates
- KofTwentyTwo.AppKit.WinUI
- KofTwentyTwo.AppKit.Wpf
- New package
- Not sure
validations:
required: false
label: Alternatives considered
+14 -9
View File
@@ -1,18 +1,23 @@
<!-- PRs target the `dev` branch (see CONTRIBUTING.md). -->
<!-- Title: a Conventional Commit header, e.g. "fix(sync): retry on transient network errors". -->
## What
<!-- What does this PR change? -->
<!-- What changes, in a sentence or two. -->
## Why
<!-- Why is it needed? Link the issue, e.g. "Fixes #12". -->
<!-- The problem or issue. "Fixes #123" closes the issue on merge. -->
## How it was tested
<!-- Tests added or changed, manual checks, screenshots for UI changes. -->
## Checklist
- [ ] `dotnet build AppKit.slnx -p:Platform=x64 -warnaserror` succeeds with **0 warnings**
- [ ] Unit tests pass and coverage of `KofTwentyTwo.AppKit` / `KofTwentyTwo.AppKit.Updates` is still **100%**
- [ ] UI changes have the same shape in WinUI **and** WPF, with a UI test in both samples
- [ ] `dotnet format AppKit.slnx --verify-no-changes --severity error` is clean
- [ ] Public API changes are intentional; breaking changes are called out for a major bump
- [ ] Docs updated (`README.md`, package READMEs) if behavior changed
- [ ] Commits are signed and signed off (`git commit -S -s`)
- [ ] Zero build warnings; format and lint are clean
- [ ] Tests added or updated; the coverage gate still passes
- [ ] Docs updated for user-visible changes
- [ ] No secrets, credentials, or personal data
- [ ] New dependencies are justified below, or none were added
- [ ] Substantial AI assistance, if any, is noted here
+30 -28
View File
@@ -1,35 +1,37 @@
# Weekly dependency updates for NuGet packages and the GitHub Actions pinned in
# .github/workflows. Minor/patch bumps are grouped into a single PR to keep the
# noise down; major bumps still arrive as individual PRs for closer review.
# Dependabot for a KofTwentyTwo repository: copy to .github/dependabot.yml and keep
# the ecosystems the repository uses. Updates are grouped weekly. A 7-day cooldown
# lets the community catch a compromised release before it reaches us; security
# updates are not delayed by the cooldown.
version: 2
updates:
- package-ecosystem: nuget
directory: "/"
# Updates land on dev like all other work and reach main via the gated PR flow.
target-branch: dev
schedule:
interval: weekly
day: monday
time: "06:00"
timezone: America/New_York
groups:
nuget-minor-patch:
applies-to: version-updates
patterns:
- "*"
update-types:
- minor
- patch
# Always: keeps SHA-pinned actions and reusable workflows current (K22-CI-11).
- package-ecosystem: github-actions
directory: "/"
target-branch: dev
directory: /
schedule:
interval: weekly
day: monday
time: "06:00"
timezone: America/New_York
cooldown:
default-days: 7
groups:
actions-all:
patterns:
- "*"
actions:
patterns: ["*"]
commit-message:
prefix: ci
# .NET: NuGet, including Directory.Packages.props and packages.lock.json.
- package-ecosystem: nuget
directory: /
schedule:
interval: weekly
day: monday
cooldown:
default-days: 7
semver-major-days: 14
groups:
minor-and-patch:
update-types: [minor, patch]
commit-message:
prefix: build
# Other ecosystems (npm, pip/uv, cargo, gomod, swift, maven, gradle, terraform,
# docker): copy the block above and change package-ecosystem and directory.
+21 -92
View File
@@ -1,101 +1,30 @@
# Continuous integration: zero-warning build, unit tests with the 100% coverage gate,
# end-to-end UI tests of the sample apps, package creation, and the formatting gate.
name: CI
# .NET build, test, coverage, and format gates (K22-CI-01, K22-CI-02, K22-TEST-10).
# Produces the required contexts `ci / build-test`, `ci / format`, and `ci / ui-tests`.
# The coverage gate holds the two UI-framework-free packages at 100% line coverage.
name: ci
on:
push:
branches: [main, dev]
pull_request:
push:
branches: [main]
permissions:
contents: read
permissions: {}
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build-test:
name: Build and test (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:
global-json-file: global.json
# -warnaserror also fails on NuGetAudit warnings, so a known-vulnerable
# dependency breaks the build.
- name: Build solution (x64, warnings as errors)
run: dotnet build AppKit.slnx -p:Platform=x64 -warnaserror
- name: Run unit tests with coverage
run: dotnet test tests/KofTwentyTwo.AppKit.Tests --no-build -p:Platform=x64 --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults
- name: Enforce 100% line coverage (AppKit, AppKit.Updates)
shell: pwsh
run: ./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages KofTwentyTwo.AppKit, KofTwentyTwo.AppKit.Updates
- name: Upload coverage
if: always()
uses: actions/upload-artifact@v7
with:
name: coverage-cobertura
path: TestResults/**/coverage.cobertura.xml
if-no-files-found: warn
# Proves every package still packs (metadata, README, dependencies) on every PR,
# not only at release time.
- name: Pack
run: dotnet pack AppKit.slnx -c Release -p:Platform=x64 -o artifacts/nuget
- name: Upload packages
uses: actions/upload-artifact@v7
with:
name: nuget-packages
path: artifacts/nuget/*
ui-tests:
# FlaUI drives the real WinUI and WPF sample apps on the runner's desktop session.
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:
global-json-file: global.json
- name: Build solution (x64)
run: dotnet build AppKit.slnx -p:Platform=x64
- name: Run UI tests
run: dotnet test tests/KofTwentyTwo.AppKit.UiTests --no-build -p:Platform=x64
format:
name: Format (style gate)
runs-on: windows-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up .NET SDK
uses: actions/setup-dotnet@v5
with:
global-json-file: global.json
- name: Restore
run: dotnet restore AppKit.slnx -p:Platform=x64
# --severity error gates real issues (analyzer errors and error-severity style
# rules), not whitespace preferences.
- name: Verify formatting
run: dotnet format AppKit.slnx --verify-no-changes --severity error --no-restore
ci:
uses: KofTwentyTwo/standards/.github/workflows/dotnet.yml@ae3df7af77e5a97df231a9f53be9d8ed785ddce5 # main (pre-release)
permissions:
contents: read # check out, build, and test; nothing is written
with:
solution: AppKit.slnx
platform: x64
coverage-packages: KofTwentyTwo.AppKit,KofTwentyTwo.AppKit.Updates
test-projects: |
tests/KofTwentyTwo.AppKit.Tests
ui-test-projects: |
tests/KofTwentyTwo.AppKit.UiTests
pack: true
+20 -46
View File
@@ -1,55 +1,29 @@
# CodeQL static analysis for C#.
#
# C# is a compiled language, so CodeQL needs a real build. We use build-mode
# "manual" and run the same x64 solution build the CI workflow uses (without
# -warnaserror - the CI workflow already gates warnings; this workflow's job is
# security analysis, and it should still scan code that has a pending warning).
name: CodeQL
# CodeQL static analysis (K22-CI-22): security-extended queries for C# (a manual x64
# build of the solution) and for the workflows themselves, blocking high severity.
name: codeql
on:
pull_request:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
# Weekly scan (Mondays 06:30 UTC) so new CodeQL queries run even when the
# code has not changed.
- cron: '30 6 * * 1'
- cron: "41 6 * * 1"
permissions:
contents: read
permissions: {}
concurrency:
group: codeql-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
name: Analyze (csharp)
runs-on: windows-latest
timeout-minutes: 120
codeql:
uses: KofTwentyTwo/standards/.github/workflows/codeql.yml@ae3df7af77e5a97df231a9f53be9d8ed785ddce5 # main (pre-release)
permissions:
# Required for github/codeql-action to upload scan results.
security-events: write
# Required to fetch public CodeQL packs.
packages: read
actions: read
contents: read
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up .NET SDK
uses: actions/setup-dotnet@v5
with:
global-json-file: global.json
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: csharp
build-mode: manual
- name: Build solution (x64)
run: dotnet build AppKit.slnx -p:Platform=x64
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v4
with:
category: '/language:csharp'
contents: read # check out the code
security-events: write # upload CodeQL results to code scanning
with:
languages: '["csharp", "actions"]'
build-mode: manual
build-command: dotnet build AppKit.slnx -p:Platform=x64
runs-on: windows-latest
setup-dotnet: true
-47
View File
@@ -1,47 +0,0 @@
# Blocks pull requests that add dependencies with known vulnerabilities or with
# licenses outside the allowlist below. Only dependencies *added or changed* by
# the PR are checked (via the GitHub dependency graph diff), so existing
# dependencies never retroactively fail this gate.
name: Dependency Review
on:
pull_request:
permissions:
contents: read
jobs:
dependency-review:
name: Dependency review
# No build happens here, so the cheaper Linux runner is fine.
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
# Needed for comment-summary-in-pr.
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Dependency review
uses: actions/dependency-review-action@v5
with:
# Fail on ANY known vulnerability, even low severity.
fail-on-severity: low
comment-summary-in-pr: on-failure
# Anything not on this list fails the check. AppKit is MIT and is
# consumed by MIT apps, so only permissive licenses are accepted.
# Extend deliberately - a new license here is a legal decision, not a
# build fix.
allow-licenses: >-
MIT,
MIT-0,
Apache-2.0,
BSD-2-Clause,
BSD-3-Clause,
0BSD,
ISC,
MS-PL,
Unlicense,
CC0-1.0
+20
View File
@@ -0,0 +1,20 @@
# Pull request gates (K22-CI-01): Conventional Commit title, DCO sign-off, and
# dependency review, from the shared KofTwentyTwo/standards workflow pinned by SHA
# (K22-CI-11). Dependabot's github-actions ecosystem keeps the pin current.
name: pr-gates
on:
pull_request:
permissions: {}
concurrency:
group: pr-gates-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
pr:
uses: KofTwentyTwo/standards/.github/workflows/pr.yml@ae3df7af77e5a97df231a9f53be9d8ed785ddce5 # main (pre-release)
permissions:
contents: read # read the commits being merged
pull-requests: read # read the pull request title and dependency diff
+62 -85
View File
@@ -1,104 +1,81 @@
# Release pipeline, triggered by pushing a tag that starts with 'v'.
# NuGet package release for a KofTwentyTwo repository (K22-CI-30, K22-REL-*).
# Copy to .github/workflows/release.yml. Pushing a protected v* tag builds, tests,
# packs, generates SBOMs, attests, and publishes the GitHub release through the shared
# reusable workflow, then pushes the packages to nuget.org.
#
# v1.2.3 -> stable GitHub release + nuget.org
# v1.2.3-beta.1 -> GitHub prerelease + nuget.org prerelease
#
# Every package is built once, attested (SLSA build provenance), attached to the
# GitHub release with a SHA256SUMS file, then pushed to nuget.org when the
# NUGET_API_KEY secret is configured. See docs/RELEASING.md.
name: Release
# nuget.org trusted publishing (one-time owner setup, no API key stored anywhere):
# nuget.org → your profile → Trusted Publishing → Add policy:
# Repository Owner: KofTwentyTwo
# Repository: AppKit
# Workflow File: release.yml (THIS file's name, not the reusable workflow's)
# Environment: release
# then set the repository variable NUGET_USER to your nuget.org profile name.
# The publish job must stay in this file: nuget.org checks that the OIDC token's
# job_workflow_ref belongs to this repository, which a job inside the reusable
# workflow (in KofTwentyTwo/standards) can never satisfy.
name: release
on:
push:
tags:
- 'v*'
- "v*"
permissions:
contents: read
permissions: {}
# One release at a time, and never cancel one halfway through publishing.
concurrency:
group: release
cancel-in-progress: false
jobs:
release:
name: Build, attest, and publish
runs-on: windows-latest
timeout-minutes: 45
uses: KofTwentyTwo/standards/.github/workflows/release-nuget.yml@ae3df7af77e5a97df231a9f53be9d8ed785ddce5 # main (pre-release)
permissions:
contents: write # create the GitHub release and upload assets
id-token: write # sign the provenance attestation (Sigstore OIDC)
attestations: write # store the attestation on the repository
env:
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
contents: write # create and publish the GitHub release
id-token: write # sign attestations
attestations: write # store attestations
with:
solution: AppKit.slnx
platform: x64
coverage-packages: KofTwentyTwo.AppKit,KofTwentyTwo.AppKit.Updates
test-projects: |
tests/KofTwentyTwo.AppKit.Tests
publish:
name: publish (nuget.org)
needs: release
if: vars.NUGET_USER != ''
runs-on: ubuntu-24.04
timeout-minutes: 15
environment: release
permissions:
contents: read
attestations: read # verify the attestations before pushing
id-token: write # trusted publishing token exchange
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up .NET SDK
uses: actions/setup-dotnet@v5
- name: Download packages
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
global-json-file: global.json
name: packages
path: packages
- name: Derive version from tag
id: version
shell: pwsh
run: |
$tag = $env:GITHUB_REF_NAME
# Strict semver 2.0; build metadata ('+...') is rejected.
$semver = '^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?$'
if ($tag -notmatch $semver) {
Write-Error "Tag '$tag' is not a release tag. Expected vMAJOR.MINOR.PATCH with an optional prerelease suffix, e.g. v1.2.3 or v1.2.3-beta.1."
exit 1
}
$version = $tag.Substring(1)
$prerelease = $version.Contains('-')
Write-Host "Tag: $tag | Version: $version | Prerelease: $prerelease"
Add-Content $env:GITHUB_OUTPUT "version=$version"
Add-Content $env:GITHUB_OUTPUT "prerelease=$($prerelease.ToString().ToLower())"
# The same gates as CI: a release can never ship what CI would reject.
- name: Build (x64, warnings as errors)
run: dotnet build AppKit.slnx -c Release -p:Platform=x64 -p:Version=${{ steps.version.outputs.version }} -warnaserror
- name: Unit tests with coverage
run: dotnet test tests/KofTwentyTwo.AppKit.Tests -c Release --no-build -p:Platform=x64 --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults
- name: Enforce 100% line coverage
shell: pwsh
run: ./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages KofTwentyTwo.AppKit, KofTwentyTwo.AppKit.Updates
- name: Pack
run: dotnet pack AppKit.slnx -c Release --no-build -p:Platform=x64 -p:Version=${{ steps.version.outputs.version }} -o artifacts/nuget
- name: Checksums
shell: pwsh
run: |
Push-Location artifacts/nuget
Get-ChildItem -File | Sort-Object Name | ForEach-Object {
"{0} {1}" -f (Get-FileHash $_.Name -Algorithm SHA256).Hash.ToLower(), $_.Name
} | Set-Content -Path SHA256SUMS -Encoding ascii
Get-Content SHA256SUMS
Pop-Location
# Anyone can verify a package came from this workflow at this tag:
# gh attestation verify <file>.nupkg --repo KofTwentyTwo/AppKit
- name: Attest build provenance
uses: actions/attest-build-provenance@v3
with:
subject-path: 'artifacts/nuget/*.nupkg'
# Releases are immutable once published, so everything lands on a draft first.
- name: Create draft release with packages
shell: pwsh
# Push only bytes that the shared release workflow built and attested.
- name: Verify attestations
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
run: |
$pre = @()
if ('${{ steps.version.outputs.prerelease }}' -eq 'true') { $pre = @('--prerelease') }
gh release create $env:GITHUB_REF_NAME --draft --verify-tag --title "AppKit ${{ steps.version.outputs.version }}" --generate-notes @pre (Get-ChildItem artifacts/nuget -File).FullName
for package in packages/*.nupkg; do
gh attestation verify "$package" --repo "$REPOSITORY" --signer-repo KofTwentyTwo/standards
done
- name: Publish the release
env:
GH_TOKEN: ${{ github.token }}
run: gh release edit $env:GITHUB_REF_NAME --draft=false
- name: NuGet login (OIDC to a one-hour API key)
id: login
uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0
with:
user: ${{ vars.NUGET_USER }}
- name: Push to nuget.org
if: env.NUGET_API_KEY != ''
run: dotnet nuget push "artifacts/nuget/*.nupkg" --api-key $env:NUGET_API_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate
env:
NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }}
run: dotnet nuget push "packages/*.nupkg" --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
+54
View File
@@ -0,0 +1,54 @@
# OpenSSF Scorecard (K22-CI-24). Scorecard's publishing API only accepts results
# from a workflow shaped exactly like this one: a single job, read-only top-level
# permissions, no top-level env or defaults, and only the allowed steps.
name: scorecard
on:
branch_protection_rule:
schedule:
- cron: "17 7 * * 1"
push:
branches: [main]
workflow_dispatch:
permissions: {}
concurrency:
group: scorecard-${{ github.ref }}
cancel-in-progress: false
jobs:
analysis:
name: analysis
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
actions: read # Scorecard reads workflow run metadata
security-events: write # upload SARIF to code scanning
id-token: write # publish results to the OpenSSF API and badge
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Run Scorecard
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
with:
results_file: scorecard.sarif
results_format: sarif
publish_results: true
- name: Keep the results
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: scorecard-sarif
path: scorecard.sarif
retention-days: 30
- name: Upload SARIF to code scanning
uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
sarif_file: scorecard.sarif
category: scorecard
+25
View File
@@ -0,0 +1,25 @@
# Security scans (K22-CI-20, K22-CI-21, K22-CI-23): gitleaks over full history,
# Trivy + OSV-Scanner over the lock files, zizmor + actionlint over the workflows.
# Runs on every pull request, every push to main, and weekly.
name: security
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "23 6 * * 1"
workflow_dispatch:
permissions: {}
concurrency:
group: security-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
security:
uses: KofTwentyTwo/standards/.github/workflows/security.yml@ae3df7af77e5a97df231a9f53be9d8ed785ddce5 # main (pre-release)
permissions:
contents: read # check out the code and its history
security-events: write # upload Trivy and zizmor SARIF to code scanning
+26 -26
View File
@@ -1,28 +1,28 @@
<Solution>
<Configurations>
<Platform Name="ARM64" />
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Folder Name="/src/">
<Project Path="src/KofTwentyTwo.AppKit/KofTwentyTwo.AppKit.csproj" />
<Project Path="src/KofTwentyTwo.AppKit.Updates/KofTwentyTwo.AppKit.Updates.csproj" />
<Project Path="src/KofTwentyTwo.AppKit.WinUI/KofTwentyTwo.AppKit.WinUI.csproj" />
<Project Path="src/KofTwentyTwo.AppKit.Wpf/KofTwentyTwo.AppKit.Wpf.csproj" />
</Folder>
<Folder Name="/samples/">
<Project Path="samples/AppKit.Sample.WinUI/AppKit.Sample.WinUI.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
<Platform Solution="*|x86" Project="x86" />
</Project>
<Project Path="samples/AppKit.Sample.Wpf/AppKit.Sample.Wpf.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/KofTwentyTwo.AppKit.Tests/KofTwentyTwo.AppKit.Tests.csproj" />
<Project Path="tests/KofTwentyTwo.AppKit.UiTests/KofTwentyTwo.AppKit.UiTests.csproj" />
</Folder>
<Folder Name="/tools/">
<Project Path="tools/BrandTool/BrandTool.csproj" />
</Folder>
<Configurations>
<Platform Name="ARM64" />
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Folder Name="/src/">
<Project Path="src/KofTwentyTwo.AppKit/KofTwentyTwo.AppKit.csproj" />
<Project Path="src/KofTwentyTwo.AppKit.Updates/KofTwentyTwo.AppKit.Updates.csproj" />
<Project Path="src/KofTwentyTwo.AppKit.WinUI/KofTwentyTwo.AppKit.WinUI.csproj" />
<Project Path="src/KofTwentyTwo.AppKit.Wpf/KofTwentyTwo.AppKit.Wpf.csproj" />
</Folder>
<Folder Name="/samples/">
<Project Path="samples/AppKit.Sample.WinUI/AppKit.Sample.WinUI.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
<Platform Solution="*|x86" Project="x86" />
</Project>
<Project Path="samples/AppKit.Sample.Wpf/AppKit.Sample.Wpf.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/KofTwentyTwo.AppKit.Tests/KofTwentyTwo.AppKit.Tests.csproj" />
<Project Path="tests/KofTwentyTwo.AppKit.UiTests/KofTwentyTwo.AppKit.UiTests.csproj" />
</Folder>
<Folder Name="/tools/">
<Project Path="tools/BrandTool/BrandTool.csproj" />
</Folder>
</Solution>
+18 -5
View File
@@ -1,24 +1,37 @@
# AppKit
Shared foundation packages for KofTwentyTwo Windows apps (WinUI 3 and WPF, .NET 10).
Every KofTwentyTwo app depends on this, so treat the public API as a contract.
Every KofTwentyTwo app depends on this, so treat the public API as a contract. The repo
follows the KofTwentyTwo standards (github.com/KofTwentyTwo/standards; local clone
`../standards`).
## Commands
```powershell
dotnet restore AppKit.slnx -p:Platform=x64 --locked-mode
dotnet build AppKit.slnx -p:Platform=x64 -warnaserror
dotnet test tests/KofTwentyTwo.AppKit.Tests --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults
./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages KofTwentyTwo.AppKit,KofTwentyTwo.AppKit.Updates
dotnet test tests/KofTwentyTwo.AppKit.UiTests # launches both sample apps on the desktop
dotnet restore AppKit.slnx -p:Platform=x64; dotnet format AppKit.slnx --verify-no-changes --severity error --no-restore
dotnet format AppKit.slnx --verify-no-changes --severity warn --no-restore
pwsh ../standards/tools/Test-RepoConformance.ps1 -LocalPath . -StaticOnly
```
## Rules
- PRs target `dev`; `main` is release-only; tags `v*` publish (see docs/RELEASING.md).
- GitHub Flow: topic branch `<type>/<desc>` → PR to `main` → squash merge. No `dev` branch.
Conventional Commit titles, `git commit -s` (DCO), signed commits. Agents never merge,
tag, release, or change repository settings.
- Zero warnings; 100% line coverage for `KofTwentyTwo.AppKit` and `KofTwentyTwo.AppKit.Updates`.
- Testable logic lives in those two packages; WinUI/WPF packages hold views and adapters only.
- UI packages are code-built (no XAML) so they work unpackaged; keep WinUI and WPF in step,
with a UI test for both samples.
- Package versions live only in `Directory.Packages.props` (central package management).
- Files are CRLF (`.editorconfig`, `.gitattributes`).
- Kingsrook style (`.editorconfig`): 3-space indent, Allman braces, `if(`, LF line endings,
three blank lines between members, a `///` header comment on every type and method,
flower-box comments inside bodies, SPDX header on every file, one type per file.
- Package versions live only in `Directory.Packages.props`; every project commits its
`packages.lock.json`, and CI restores in locked mode.
- `samples/Shared/*.cs` are linked into both sample projects: run `dotnet format` on the
solution only after checking it did not write merge-conflict markers into them.
- Attack-surface changes update `docs/security/threat-model.md`; expensive-to-reverse
decisions get an ADR in `docs/adr/`.
+90 -35
View File
@@ -1,23 +1,25 @@
# Contributing to AppKit
AppKit is the shared foundation under every KofTwentyTwo Windows app, so a change here
reaches all of them. That raises the bar: changes are small, tested, and documented.
Thanks for helping. AppKit follows the
[KofTwentyTwo standards](https://github.com/KofTwentyTwo/standards); this page is the
short version of what they ask of a contribution. AppKit sits under every KofTwentyTwo
Windows app, so a change here reaches all of them: changes are small, tested, and
documented.
## Prerequisites
## Before you start
- Windows 11, or Windows 10 version 1809 (build 17763) or later
- [.NET 10 SDK](https://dotnet.microsoft.com/) (`global.json` pins the feature band)
- Visual Studio 2026 with the Windows App SDK workload, optional, for working on the samples
For anything beyond a small fix, open an issue first so the approach can be agreed
before you invest time. Questions and proposals are welcome in
[Issues](https://github.com/KofTwentyTwo/AppKit/issues).
## Branch model
## Setup
- **`dev`** is the integration branch. **Pull requests target `dev`.**
- **`main`** is the release branch. It moves only by reviewed PRs from `dev`, and release
tags are cut from it.
## Building and testing
Prerequisites: Windows 10 1809 or later, the .NET 10 SDK (`global.json` pins the feature
band), and optionally Visual Studio 2026 or Rider. The commands below are exactly what
CI runs; run them before pushing.
```powershell
dotnet restore AppKit.slnx -p:Platform=x64 --locked-mode
dotnet build AppKit.slnx -p:Platform=x64 -warnaserror
# Unit tests and the 100% line-coverage gate (KofTwentyTwo.AppKit, KofTwentyTwo.AppKit.Updates)
@@ -27,35 +29,88 @@ dotnet test tests/KofTwentyTwo.AppKit.Tests --settings coverage.runsettings --co
# End-to-end UI tests: FlaUI launches both sample apps (needs an interactive desktop)
dotnet test tests/KofTwentyTwo.AppKit.UiTests
dotnet format AppKit.slnx --verify-no-changes --severity error
# Style gate (Kingsrook layout, analyzers, naming)
dotnet format AppKit.slnx --verify-no-changes --severity warn --no-restore
```
## What CI enforces
`dotnet format` (without `--verify-no-changes`) fixes layout, `var` use, usings, and
line endings. Two Kingsrook rules it cannot apply are checked in review: three blank
lines between members and a header comment on every type and method (Rider and
ReSharper apply the blank lines from `.editorconfig`). Changing a package version means
updating `Directory.Packages.props` and committing the regenerated `packages.lock.json`
files (`dotnet restore AppKit.slnx -p:Platform=x64 --force-evaluate`).
| Check | Gate |
Install the git hooks once per clone. They run the same secret, workflow, and
commit-message checks CI does:
```sh
pre-commit install --hook-type pre-commit --hook-type commit-msg
```
## Workflow
1. Branch from `main` with a short-lived topic branch named `<type>/<short-description>`,
for example `feat/tray-icon` or `fix/42-splash-never-dismisses`. There is no `dev`
branch.
2. Commit with [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
(`feat: ...`, `fix(updates): ...`). Every commit is **signed** and **signed off**:
`git commit -S -s` (see *Sign-off* below).
3. Open a pull request against `main`. Its title must itself be a Conventional Commit
header: pull requests are **squash-merged**, so the title becomes the commit
message on `main`.
4. Every required check must pass, and every review thread must be resolved, before
the pull request merges.
## What makes a contribution acceptable
- The build has **zero warnings**, and the format gate is clean.
- New behavior has tests; a bug fix has a test that fails without the fix.
- Line coverage of `KofTwentyTwo.AppKit` and `KofTwentyTwo.AppKit.Updates` stays at
**100%** ([ADR-0003](docs/adr/0003-coverage-scope.md)). Logic goes in those packages; the
WinUI and WPF packages hold views and framework adapters only.
- A shell feature added to the WinUI package gets the same shape in the WPF package and a
UI test in both samples.
- Public API changes are deliberate: a breaking change needs a major version and a note
in the pull request.
- User-visible changes update the docs in the same pull request; attack-surface changes
update the [threat model](docs/security/threat-model.md).
- No secrets, credentials, or personal data, ever.
- New dependencies are justified in the pull request description.
## Checks that gate a merge
| Check | What it verifies |
| --- | --- |
| **Build and test (x64)** | Zero-warning build (NuGetAudit included), unit tests, **100% line coverage** of the two framework-free packages, and every package still packs |
| **UI end-to-end tests (x64)** | Splash, About, crash net, log window, settings, and the update flow, in both the WinUI and WPF samples |
| **Format (style gate)** | `dotnet format --verify-no-changes --severity error` |
| **Dependency review** | New dependencies: no known vulnerabilities, permissive licenses only |
| **CodeQL** | Static security analysis on PRs to `main` and weekly |
| `pr / title` | The PR title is a Conventional Commit header |
| `pr / dco` | Every commit is signed off |
| `pr / dependency-review` | Added dependencies have no known vulnerabilities and an allowed license |
| `security / secrets`, `security / sca`, `security / workflows` | No secrets, no vulnerable or malicious dependencies, safe workflows |
| `codeql / analyze (csharp)`, `codeql / analyze (actions)` | No high-severity static analysis findings |
| `ci / build-test` | Locked restore, zero-warning build, unit tests, 100% coverage gate, packages pack |
| `ci / format` | `dotnet format --verify-no-changes --severity warn` |
| `ci / ui-tests` | FlaUI end-to-end tests of both sample apps |
## Design rules
## Sign-off (Developer Certificate of Origin)
- **Logic goes in the framework-free packages.** If code can be tested without a UI, it
lives in `KofTwentyTwo.AppKit` or `KofTwentyTwo.AppKit.Updates` and is covered there.
The WinUI and WPF packages hold views and framework adapters only.
- **UI packages are built in code, not XAML.** XAML compiled into a library has to be
merged into the host app's resources, which is fragile for unpackaged apps.
- **Nothing optional may crash the app.** Logging, settings, and updates report failures
as values or log entries, never as exceptions escaping to the user.
- **WinUI and WPF stay in step.** A shell feature added to one package gets the same
shape in the other, and a UI test in both samples.
- **Never write a secret to disk, logs, or process output.** Secrets go through
`ISecretVault`.
- **Breaking changes are deliberate.** Every app depends on this API. A breaking change
needs a major version bump and a note in the release.
By signing off a commit you certify the
[Developer Certificate of Origin 1.1](https://developercertificate.org/): that you
wrote the change, or otherwise have the right to submit it under the project's
license. `git commit -s` appends the sign-off:
```text
Signed-off-by: Your Name <your.email@example.com>
```
The name and email must match the commit author. To add missing sign-offs to a
branch, run `git rebase --signoff main` and force-push the branch.
## AI-assisted contributions
AI coding tools are welcome. You remain the author: you must understand, test, and be
able to explain every line you submit, and you sign it off as your own. Note
substantial AI assistance in the pull request description.
## License
By contributing, you agree that your contributions are licensed under the [MIT License](LICENSE).
By contributing, you agree that your contributions are licensed under the project's
[MIT License](LICENSE).
+65 -38
View File
@@ -1,45 +1,72 @@
<Project>
<!-- Local quality gate: every project builds with warnings as errors and the
recommended analyzers + .editorconfig code style enforced, so problems fail
at the desk exactly like they fail in CI. -->
<PropertyGroup>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<Deterministic>true</Deterministic>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
</PropertyGroup>
<!-- KofTwentyTwo .NET quality gate (standards: K22-CODE-CS, from
KofTwentyTwo/standards configs/dotnet). Every rule fails `dotnet build` locally
exactly as it fails in CI. -->
<PropertyGroup>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
<!-- Single version source for every package in the repo; they always release
together. Local builds are <prefix>-dev; release builds pass an explicit
-p:Version=<tag-version>, which overrides both parts. SourceLink appends the
git commit as "+<sha>" on AssemblyInformationalVersion. -->
<PropertyGroup>
<VersionPrefix>0.1.0</VersionPrefix>
<VersionSuffix>dev</VersionSuffix>
</PropertyGroup>
<!-- Warnings are errors; analyzers and .editorconfig style run in every build. -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<MSBuildTreatWarningsAsErrors>true</MSBuildTreatWarningsAsErrors>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<!-- Required for IDE0005 (unused usings) to run during build. CS1591 (missing XML
doc) stays on for packable projects and is silenced for everything else below. -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<!-- Package metadata shared by every packable project. -->
<PropertyGroup>
<Authors>James Maes</Authors>
<Copyright>Copyright (c) 2026 James Maes</Copyright>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/KofTwentyTwo/AppKit</PackageProjectUrl>
<RepositoryUrl>https://github.com/KofTwentyTwo/AppKit</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageTags>windows;desktop;winui;wpf;velopack</PackageTags>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
</PropertyGroup>
<!-- Reproducible builds with source-linked symbols. -->
<Deterministic>true</Deterministic>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<!-- Reproducible paths in CI-built packages (SourceLink maps them back to GitHub). -->
<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true'">
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
</PropertyGroup>
<!-- Supply chain: audit direct AND transitive packages; pin the whole graph. -->
<NuGetAudit>true</NuGetAudit>
<NuGetAuditMode>all</NuGetAuditMode>
<NuGetAuditLevel>low</NuGetAuditLevel>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<!-- CI: normalized paths, and restore must match the committed lock files exactly. -->
<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true' or '$(CI)' == 'true'">
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
<RestoreLockedMode>true</RestoreLockedMode>
</PropertyGroup>
<!-- Only shipped libraries must document their public API. -->
<PropertyGroup Condition="'$(IsPackable)' != 'true'">
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<!-- Repository-wide analyzers; versions live in Directory.Packages.props. -->
<PackageReference Include="Meziantou.Analyzer" PrivateAssets="all" IncludeAssets="runtime; build; native; contentfiles; analyzers" />
</ItemGroup>
<!-- Single version source for every package in the repo; they always release
together. Local builds are <prefix>-dev; release builds pass an explicit
-p:Version=<tag-version>, which overrides both parts. SourceLink appends the
git commit as "+<sha>" on AssemblyInformationalVersion. -->
<PropertyGroup>
<VersionPrefix>0.1.0</VersionPrefix>
<VersionSuffix>dev</VersionSuffix>
</PropertyGroup>
<!-- Package metadata shared by every packable project. -->
<PropertyGroup>
<Authors>James Maes</Authors>
<Copyright>Copyright (c) 2026 James Maes (KofTwentyTwo)</Copyright>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/KofTwentyTwo/AppKit</PackageProjectUrl>
<RepositoryUrl>https://github.com/KofTwentyTwo/AppKit</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageTags>windows;desktop;winui;wpf;velopack</PackageTags>
</PropertyGroup>
</Project>
+24 -18
View File
@@ -1,24 +1,30 @@
<Project>
<!-- Central package management: every version lives here, so Dependabot bumps
one line and all projects move together. -->
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<!-- Central package management (K22-CODE-CS-08): every version lives here, so
Dependabot bumps one line and all projects move together. Transitive pinning
lets a version here override a vulnerable transitive dependency. -->
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Velopack" Version="1.2.161" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.5.1" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.2705" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
</ItemGroup>
<ItemGroup Label="Analyzers">
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.296" />
</ItemGroup>
<ItemGroup Label="Tests">
<PackageVersion Include="coverlet.collector" Version="10.1.0" />
<PackageVersion Include="FlaUI.UIA3" Version="5.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.0" />
</ItemGroup>
<ItemGroup>
<PackageVersion Include="Velopack" Version="1.2.161" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.5.1" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.2705" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
</ItemGroup>
<ItemGroup Label="Tests">
<PackageVersion Include="coverlet.collector" Version="10.1.0" />
<PackageVersion Include="FlaUI.UIA3" Version="5.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.0" />
</ItemGroup>
</Project>
+98 -38
View File
@@ -7,44 +7,71 @@ log viewer, a crash safety net, theming, and Velopack self-update with stable/de
channels. Extracted from [gclo](https://github.com/KofTwentyTwo/gclo), where every piece
shipped in a 1.0 release first.
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/KofTwentyTwo/AppKit/badge)](https://scorecard.dev/viewer/?uri=github.com/KofTwentyTwo/AppKit)
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
![.NET 10](https://img.shields.io/badge/.NET-10-blueviolet)
![Platform: Windows](https://img.shields.io/badge/platform-Windows%2010%2F11-blue)
| | |
| --- | --- |
| **Tier** | Product ([scope](https://github.com/KofTwentyTwo/standards/blob/main/policies/README.md#scope)) |
| **Standards** | Adopting [KofTwentyTwo standards](https://github.com/KofTwentyTwo/standards) (pre-v1.0); this README will say *Conforms to KofTwentyTwo standards v1.0* once the conformance checker passes |
| **Latest release** | [Releases](https://github.com/KofTwentyTwo/AppKit/releases/latest) (none yet) |
## Packages
| Package | Target | What it gives you |
| --- | --- | --- |
| `KofTwentyTwo.AppKit` | `net10.0` | `AppInfo` identity, `AppPaths`, `BuildVersion`, `FileActivityLog` + `LogTail`, `SettingsStore<T>` + `ShellSettings`, `CredentialManagerVault`, `IUserPrompter` |
| `KofTwentyTwo.AppKit.Updates` | `net10.0` | `VelopackStartup`, `VelopackUpdateService` (GitHub Releases, stable/dev channel), `UpdateCoordinator` (the "Check for updates" flow) |
| `KofTwentyTwo.AppKit.WinUI` | `net10.0-windows10.0.19041.0` | `SplashOverlay`, `AboutDialog`, `LogWindow`, `DialogGuard`, `ContentDialogPrompter`, `WinUIShell` (crash net, theme), DPI-aware window helpers |
| `KofTwentyTwo.AppKit.WinUI` | `net10.0-windows10.0.19041.0` | `SplashOverlay`, `AboutDialog`, `LogWindow`, `DialogGuard`, `ContentDialogPrompter`, `WinUIShell` (crash net, theme), DPI-aware `WindowExtensions` |
| `KofTwentyTwo.AppKit.Wpf` | `net10.0-windows` | `SplashOverlay`, `AboutWindow`, `LogWindow`, `MessageBoxPrompter`, `WpfShell` (crash net, Fluent theme) |
The two framework-free packages are held to **100% line coverage** in CI. The UI packages
are built in code rather than XAML, so they work identically in packaged (MSIX) and
unpackaged (Velopack) apps, and are exercised end-to-end by FlaUI tests that drive the
sample apps.
The two UI-framework-free packages are held to **100% line coverage** in CI. The UI
packages are built in code rather than XAML, so they work identically in packaged (MSIX)
and unpackaged (Velopack) apps, and are exercised end-to-end by FlaUI tests that drive
the sample apps ([ADR-0001](docs/adr/0001-code-built-ui.md)).
## Using it
## Install
New apps should start from the app template rather than wiring this by hand; it
references these packages and has everything below already in place. To add AppKit to
an existing WinUI app:
The packages are published to nuget.org with the GitHub release of each version.
Reference them through central package management:
```xml
<!-- Directory.Packages.props -->
<PackageVersion Include="KofTwentyTwo.AppKit.WinUI" Version="0.1.0" /> <!-- or .Wpf -->
<!-- the app's .csproj -->
<PackageReference Include="KofTwentyTwo.AppKit.WinUI" />
<PackageReference Include="Microsoft.WindowsAppSDK" />
```
The UI packages bring in `KofTwentyTwo.AppKit` and `KofTwentyTwo.AppKit.Updates`. A
WinUI app references `Microsoft.WindowsAppSDK` itself (the WinUI package does not carry
its build targets) and copies its brand assets (`Assets/Brand/app-icon.svg`,
`app.ico`) to the output directory.
## Usage
New apps should start from the KofTwentyTwo Windows app template, which has everything
below in place; the design is the
[Windows desktop reference architecture](https://github.com/KofTwentyTwo/standards/blob/main/architecture/windows-desktop-app.md).
To add AppKit to an existing WinUI app:
```csharp
// One identity, declared once.
static class MyApp
internal static class MyApp
{
public static AppInfo Info { get; } = new()
{
Id = "myapp", // data folder, log prefix, credential prefix
DisplayName = "My App",
Tagline = "Does one thing well",
RepositoryUrl = new Uri("https://github.com/KofTwentyTwo/myapp"), // also the update feed
Author = "James Maes",
Copyright = "Copyright (c) 2026 James Maes",
};
public static AppPaths Paths { get; } = new(Info);
public static AppInfo Info { get; } = new()
{
Id = "myapp", // data folder, log prefix, credential prefix
DisplayName = "My App",
Tagline = "Does one thing well",
RepositoryUrl = new Uri("https://github.com/KofTwentyTwo/myapp"), // also the update feed
Author = "James Maes",
Copyright = "Copyright (c) 2026 James Maes",
};
public static AppPaths Paths { get; } = new(Info);
}
// Program.Main (with DISABLE_XAML_GENERATED_MAIN): Velopack goes first.
@@ -57,41 +84,74 @@ WinUIShell.ApplyTheme(this, settings.ThemeKind);
// Main window
SplashOverlay.ShowOver(RootGrid, MyApp.Info, settings);
var updates = new UpdateCoordinator(
new VelopackUpdateService(MyApp.Info.RepositoryUrl!),
new ContentDialogPrompter(() => Content.XamlRoot),
MyApp.Info.DisplayName, Log);
new VelopackUpdateService(MyApp.Info.RepositoryUrl!),
new ContentDialogPrompter(() => Content.XamlRoot),
MyApp.Info.DisplayName, Log);
// Help menu
await updates.CheckInteractivelyAsync();
await AboutDialog.ShowAsync(MyApp.Info, Content.XamlRoot);
LogWindow.ShowSingle(Log, MyApp.Info);
```
Apps reference `Microsoft.WindowsAppSDK` themselves (the WinUI package does not carry its
build targets), and copy their brand assets (`Assets/Brand/app-icon.svg`, `app.ico`) to
the output directory. See [`samples/`](samples) for complete WinUI and WPF apps.
[`samples/`](samples) has complete WinUI and WPF apps wired to every service.
## Repository layout
## Building from source
```
src/ the four packages
tests/ unit tests (100% gate) and FlaUI end-to-end tests of the samples
samples/ minimal WinUI and WPF apps wired to every service
tools/ BrandTool: generates app-icon.svg and a multi-size app.ico from one definition
build/ scripts CI shares with every app repo (coverage gate)
```
Prerequisites:
## Building
- Windows 10 1809 or later (the UI packages and samples are Windows-only)
- .NET 10 SDK (the feature band is pinned by `global.json`)
- Optional: Visual Studio 2026 or Rider for the samples
```powershell
dotnet restore AppKit.slnx -p:Platform=x64 --locked-mode
dotnet build AppKit.slnx -p:Platform=x64 -warnaserror # zero warnings, always
dotnet test tests/KofTwentyTwo.AppKit.Tests --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults
./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages KofTwentyTwo.AppKit,KofTwentyTwo.AppKit.Updates
dotnet test tests/KofTwentyTwo.AppKit.UiTests # drives the samples; needs a desktop
dotnet format AppKit.slnx --verify-no-changes --severity error
dotnet format AppKit.slnx --verify-no-changes --severity warn --no-restore
dotnet pack AppKit.slnx -c Release -p:Platform=x64 -o artifacts/nuget
```
See [CONTRIBUTING.md](CONTRIBUTING.md) for the branch model and PR expectations, and
[docs/RELEASING.md](docs/RELEASING.md) for how packages are published.
Dependencies are declared in `Directory.Packages.props` (central package management)
and locked per project in `packages.lock.json`; see [CONTRIBUTING.md](CONTRIBUTING.md)
for the full local workflow.
```text
src/ the four packages
tests/ unit tests (100% gate) and FlaUI end-to-end tests of the samples
samples/ minimal WinUI and WPF apps wired to every service
tools/ BrandTool: generates app-icon.svg and a multi-size app.ico from one definition
build/ Assert-Coverage.ps1, the coverage gate
docs/ release process, architecture decisions, threat model
```
## Verifying a release
Every release asset (`.nupkg`, `.snupkg`, and the CycloneDX SBOMs) is listed in
`SHA256SUMS` and has a build-provenance attestation signed by the shared KofTwentyTwo
release workflow:
```powershell
gh release download v0.1.0 --repo KofTwentyTwo/AppKit
sha256sum --check SHA256SUMS
gh attestation verify KofTwentyTwo.AppKit.0.1.0.nupkg --repo KofTwentyTwo/AppKit --signer-repo KofTwentyTwo/standards
```
## Support
Only the latest released minor version receives fixes, including security fixes; see
[SECURITY.md](SECURITY.md#supported-versions).
## Security
Report vulnerabilities privately; see [SECURITY.md](SECURITY.md). The attack surface is
described in the [threat model](docs/security/threat-model.md).
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md). Releases: [docs/RELEASING.md](docs/RELEASING.md).
## License
+43 -23
View File
@@ -1,33 +1,53 @@
# Security Policy
## Supported versions
Only the **latest release** of the AppKit packages receives security fixes. Apps built on
AppKit pick up a fix by updating their package reference (Dependabot opens that PR).
## Reporting a vulnerability
**Please do not open a public issue for security problems.**
**Do not open a public issue for a security problem.**
This repository has GitHub **private vulnerability reporting** enabled:
Report it privately through GitHub's
[private vulnerability reporting](https://github.com/KofTwentyTwo/AppKit/security/advisories/new)
(the repository's **Security** tab, then **Report a vulnerability**). That is the only
reporting channel.
1. Go to the repository's [Security tab](https://github.com/KofTwentyTwo/AppKit/security).
2. Click **Report a vulnerability**.
3. Describe the issue, how to reproduce it, and the impact you see.
Security contact: **James Maes** ([@KofTwentyTwo](https://github.com/KofTwentyTwo)),
maintainer.
That is the only reporting channel. AppKit is maintained by one person, so responses are
best-effort: normally an acknowledgment within **7 days**.
Please include the affected package and version, how to reproduce it, and the impact
you see. AppKit's attack surface is described in its
[threat model](docs/security/threat-model.md).
## Security-relevant design
## What to expect
- **Secrets** (tokens, keys) are stored only through `ISecretVault`. The Windows
implementation uses the per-user Credential Manager (DPAPI-protected) and clears its
plaintext buffer after writing. Secrets never reach settings files or logs.
- **Updates** come only from the app's own GitHub Releases over HTTPS via Velopack, and
each package's integrity is checked by Velopack before it is applied.
- **Releases** of AppKit carry SLSA build-provenance attestations and a `SHA256SUMS` file.
Verify a package with:
KofTwentyTwo follows coordinated vulnerability disclosure. Timeframes are counted
from the day the report arrives and come from the
[KofTwentyTwo security program](https://github.com/KofTwentyTwo/standards/blob/main/policies/security-program.md)
(targets until the standards reach v1.0.0, commitments from then on):
```powershell
gh attestation verify KofTwentyTwo.AppKit.<version>.nupkg --repo KofTwentyTwo/AppKit
```
| Step | Target |
| --- | --- |
| Acknowledge the report | 7 days |
| Assess it: confirm or reject, with a severity | 14 days |
| Release a fix for a CRITICAL vulnerability | 7 days |
| Release a fix for a HIGH vulnerability | 30 days |
| Release a fix for a MEDIUM vulnerability | 90 days |
| Release a fix for a LOW vulnerability | The next release |
| Publish the advisory (GitHub Security Advisory, CVE where applicable) | When the fix ships, or 90 days after the report, whichever comes first |
Reporters are credited in the advisory unless they ask not to be.
## Supported versions
| Version | Security fixes |
| --- | --- |
| Latest minor release (`0.1.x` once published) | Yes |
| Older | No; upgrade to the latest release |
All four packages release together under one version. Only the latest minor release
receives fixes; a line stops receiving security fixes when the next minor version is
released. After a new major version, the previous major's last minor receives security
fixes for 6 months.
## Published vulnerabilities
Fixed vulnerabilities are published as
[GitHub Security Advisories](https://github.com/KofTwentyTwo/AppKit/security/advisories).
+14 -14
View File
@@ -1,21 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Coverage contract for the 100% gate: every hand-written line in the framework-free
packages counts. Excluded, with intent:
- GeneratedCodeAttribute: source-generated regexes and JSON contexts.
- ExcludeFromCodeCoverageAttribute: thin pass-throughs to Velopack that need an
installed app or the network (each carries a justification at its declaration).
- GeneratedCodeAttribute: source-generated regexes and JSON contexts.
- ExcludeFromCodeCoverageAttribute: thin pass-throughs to Velopack that need an
installed app or the network (each carries a justification at its declaration).
The WinUI and WPF packages are not measured: their views need a live compositor and
are exercised by the sample apps' UI smoke tests. Their logic lives here instead. -->
<RunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat Code Coverage">
<Configuration>
<Format>cobertura</Format>
<Include>[KofTwentyTwo.AppKit]*,[KofTwentyTwo.AppKit.Updates]*</Include>
<ExcludeByAttribute>GeneratedCodeAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat Code Coverage">
<Configuration>
<Format>cobertura</Format>
<Include>[KofTwentyTwo.AppKit]*,[KofTwentyTwo.AppKit.Updates]*</Include>
<ExcludeByAttribute>GeneratedCodeAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>
+56 -37
View File
@@ -1,8 +1,14 @@
# Releasing AppKit
Releases are automated by [`.github/workflows/release.yml`](../.github/workflows/release.yml).
Pushing a `v*` tag builds, tests, packs, attests, and publishes all four packages
together; they always share one version.
Releases follow the
[KofTwentyTwo release standard](https://github.com/KofTwentyTwo/standards/blob/main/standards/releases.md).
Pushing a protected `v*` tag on `main` runs [`.github/workflows/release.yml`](../.github/workflows/release.yml),
which calls the shared `release-nuget.yml` workflow in KofTwentyTwo/standards. All four
packages always release together under one version.
> **Status:** the shared workflows are pinned to a commit on KofTwentyTwo/standards
> `main` while the standards are pre-release; they move to a release tag's commit once
> KofTwentyTwo/standards publishes `v1.0.0` (Dependabot proposes the update).
## Versioning
@@ -15,56 +21,69 @@ The version is the tag without the leading `v`, in strict [semver](https://semve
AppKit is a library every app depends on, so semver is a promise:
- **major**: any breaking change to a public API (renames, removed members, changed behavior apps rely on)
- **major**: any breaking change to a public API
- **minor**: new capabilities, backward compatible
- **patch**: fixes only
`Directory.Build.props` holds `VersionPrefix`, the next expected release. Local builds are
`<prefix>-dev`. Bump the prefix on `dev` right after each stable release.
`Directory.Build.props` holds `VersionPrefix`, the next expected release; local builds
are `<prefix>-dev`. Bump the prefix right after each release, in a pull request.
## What a release publishes
## What a release does
| Target | Condition |
| --- | --- |
| GitHub Release: the four `.nupkg` files, their `.snupkg` symbol packages, `SHA256SUMS` | always |
| Build-provenance attestation for every `.nupkg` | always |
| nuget.org | the `NUGET_API_KEY` repository secret is set |
The shared workflow (SLSA Build L3: the build definition lives in KofTwentyTwo/standards,
not here):
`NUGET_API_KEY` is a nuget.org API key with **push** scope limited to the glob
`KofTwentyTwo.AppKit*`. Set it under **Settings → Secrets and variables → Actions**.
Without it, the release still succeeds and the packages are only on GitHub.
1. Validates the tag and re-runs every gate that guards `main`: locked restore,
zero-warning build, unit tests, and the 100% coverage gate.
2. Packs the four packages and their symbol packages.
3. Generates a CycloneDX 1.6 SBOM per package (Syft, from `packages.lock.json`) and
attests it.
4. Writes `SHA256SUMS` over every asset and attests build provenance.
5. Creates a draft GitHub Release with the assets and generated notes, then publishes it
(releases are immutable).
Then the `publish` job in this repository's `release.yml` downloads the packages,
verifies their attestations were signed by the shared workflow, and pushes them to
nuget.org through **trusted publishing** (OIDC; no API key is stored anywhere).
## One-time setup (owner)
1. **nuget.org trusted publishing policy:** nuget.org → your profile → *Trusted
Publishing* → *Add policy*:
- Repository owner: `KofTwentyTwo`
- Repository: `AppKit`
- Workflow file: `release.yml`
- Environment: `release`
2. **Repository variable** `NUGET_USER` (Settings → Secrets and variables → Actions →
Variables): your nuget.org profile name. Without it the `publish` job is skipped and
the release still succeeds on GitHub.
3. **Environment** `release` (Settings → Environments) with a deployment rule that allows
only tags matching `v*`.
## Cutting a release
1. Merge `dev` into `main` through a PR, with every check green.
2. Tag `main` and push the tag:
1. Make sure `main` is green and the conformance check passes:
`pwsh ../standards/tools/Test-RepoConformance.ps1 -Repository KofTwentyTwo/AppKit -LocalPath .`
2. For a minor or major release, open a release checklist issue from the standards
template and complete it (security assessment, threat model review, scan results).
3. Tag `main` and push the tag:
```powershell
git checkout main
git switch main
git pull
git tag v0.1.0
git tag -s v0.1.0 -m "v0.1.0"
git push origin v0.1.0
```
3. Watch the **Release** workflow. It re-runs the zero-warning build, the unit tests, and
the coverage gate, then packs, writes checksums, attests, creates a **draft** release
with the packages and generated notes, publishes it, and pushes to nuget.org.
4. Verify: the release lists eight package files plus `SHA256SUMS`, and
`gh attestation verify <package>.nupkg --repo KofTwentyTwo/AppKit` succeeds.
5. On `dev`, bump `VersionPrefix` in `Directory.Build.props`.
4. Watch the **release** workflow, then verify the release as described in the
[README](../README.md#verifying-a-release).
5. Open a pull request that bumps `VersionPrefix` in `Directory.Build.props`.
## When something fails
- **Before "Publish the release"**: nothing is public. Delete the draft
- **Before the release is published:** nothing is public. Delete the draft
(`gh release delete v0.1.0 --yes`), fix the cause, and re-run the workflow.
- **At "Push to nuget.org"**: the GitHub release is published and immutable, so do not
re-run the job. Push by hand from the release's assets:
```powershell
gh release download v0.1.0 --pattern "*.nupkg" --dir nupkgs
dotnet nuget push "nupkgs/*.nupkg" --api-key <key> --source https://api.nuget.org/v3/index.json --skip-duplicate
```
- **A published release is broken**: never reuse its version. nuget.org versions are
permanent (they can only be unlisted). Fix forward with a new patch release, and unlist
the bad version on nuget.org.
- **At `publish (nuget.org)`:** the GitHub release is published and immutable; re-run
only the failed `publish` job from the Actions page.
- **A published release is broken:** never reuse its version. Fix forward with a new
patch release, and unlist the bad version on nuget.org.
+40
View File
@@ -0,0 +1,40 @@
# ADR-0001: Build the WinUI and WPF views in code, not XAML
- **Status:** Accepted
- **Date:** 2026-10-09
- **Pull request:** recorded retroactively for the initial design
## Context
The splash, About, and log viewer screens are shared by every KofTwentyTwo app through
NuGet packages. Most of those apps ship unpackaged through Velopack, not as MSIX. A
WinUI XAML library compiles its markup into `.xbf` and `.pri` resources that the host
app must merge into its own resource index; that merge is reliable for packaged apps
and fragile for unpackaged ones (missing-resource crashes at runtime that no build
step catches).
## Options considered
### Option A: XAML user controls in the libraries
- Pros: familiar authoring, designer support.
- Cons: resource merging in unpackaged hosts; two build pipelines (WinUI and WPF XAML
compilers) inside libraries; harder to keep the WinUI and WPF versions identical.
### Option B: views built in C#
- Pros: a plain assembly with no resources to merge, so it works the same packaged or
not; WinUI and WPF versions can mirror each other line for line.
- Cons: layout is more verbose to read and write.
## Decision
Option B. The UI packages construct their visual trees in C#. Host apps still use XAML
for their own windows.
## Consequences
- The packages work in packaged and unpackaged apps without extra setup.
- Views are a little harder to restyle; theme resources are looked up at runtime.
- Security: none.
- The FlaUI tests drive the sample apps to prove the views render and behave.
@@ -0,0 +1,39 @@
# ADR-0002: Isolate Velopack in KofTwentyTwo.AppKit.Updates
- **Status:** Accepted
- **Date:** 2026-10-09
- **Pull request:** recorded retroactively for the initial design
## Context
Self-update through Velopack is right for per-user desktop apps, but not every
KofTwentyTwo app should carry it: a Windows service is installed by an MSI and updated
through winget or the MSI, and a library consumer may want identity and logging
without any update machinery.
## Options considered
### Option A: Velopack inside KofTwentyTwo.AppKit
- Pros: one package fewer.
- Cons: every consumer, including services and CLIs, pulls in Velopack and its native
update hooks.
### Option B: a separate KofTwentyTwo.AppKit.Updates package
- Pros: the core package stays dependency-free; update logic (`UpdateCoordinator`) is
still shared by WinUI and WPF; apps that do not self-update simply do not reference it.
- Cons: one more package to version (all four release together, so this costs little).
## Decision
Option B. Velopack and the update flow live in `KofTwentyTwo.AppKit.Updates`; the UI
packages reference it because their apps self-update.
## Consequences
- `KofTwentyTwo.AppKit` has no third-party runtime dependency.
- The Velopack pass-through is isolated behind `IUpdateBackend`, so every decision
around it is unit-tested and only the thin adapter is excluded from coverage.
- Security: the update feed is an attack surface only for apps that reference this
package (see the threat model, T1–T3).
+41
View File
@@ -0,0 +1,41 @@
# ADR-0003: Hold the UI-framework-free packages at 100% line coverage
- **Status:** Accepted
- **Date:** 2026-10-09
- **Pull request:** recorded retroactively for the initial design
## Context
The KofTwentyTwo testing standard requires 100% line coverage for libraries published
for others to consume and for UI-free core logic
([K22-TEST-10](https://github.com/KofTwentyTwo/standards/blob/main/standards/testing.md#coverage)).
WinUI and WPF views need a live compositor and a desktop session, which unit tests do
not have.
## Options considered
### Option A: measure every package
- Pros: one number.
- Cons: the view packages cannot reach 100% without a desktop, so the gate would be
lowered for everyone or riddled with exclusions.
### Option B: measure KofTwentyTwo.AppKit and KofTwentyTwo.AppKit.Updates at 100%
- Pros: the gate is meaningful and cheap to keep; it pushes logic out of the views and
into tested code.
- Cons: the view packages rely on the FlaUI end-to-end tests instead of a number.
## Decision
Option B. `coverage.runsettings` measures only the two framework-free packages, and
`build/Assert-Coverage.ps1` fails CI below 100%. Exclusions use
`[ExcludeFromCodeCoverage(Justification = ...)]` only for thin pass-throughs that need
an installed app or the network.
## Consequences
- New logic belongs in the framework-free packages; views stay thin.
- The UI packages are verified by the sample apps' UI tests in CI.
- Security: none directly; tested code paths include the secret vault and the update
guarding.
+93
View File
@@ -0,0 +1,93 @@
# Threat model: AppKit
AppKit is a set of libraries, so its threats are the ones it creates inside the apps
that use it. This model covers every external interface the four packages open; each
app's own threat model starts from this one ([K22-SDLC-03](https://github.com/KofTwentyTwo/standards/blob/main/policies/sdlc.md#plan-and-design)).
- **Version reviewed:** 0.1.0-dev (before the first release) · **Last reviewed:** 2026-10-09
## 1. What the product is and does
Four NuGet packages that give a KofTwentyTwo Windows desktop app its identity, per-user
data paths, logging, settings, secret storage, splash/About/log-viewer UI, crash net,
theming, and Velopack self-update from the app's GitHub Releases. AppKit runs inside
the host app's process, with the logged-in user's rights; it has no server side and
opens no listening sockets.
## 2. Actors and components
| Actor or component | Description | Trust level |
| --- | --- | --- |
| User | The person running the host app | Trusted for their own data |
| Host app | The application that references AppKit | Trusted (same process) |
| Other local processes of the same user | Can read and write the user's files | Not trusted, but AppKit cannot defend against them |
| GitHub Releases (update feed) | Serves release metadata and Velopack packages over HTTPS | Untrusted network content; integrity checked |
| Velopack | Downloads, verifies, and applies update packages | Trusted dependency |
| Windows Credential Manager | Stores secrets per user, protected by DPAPI | Trusted OS component |
## 3. Data flow and trust boundaries
```mermaid
flowchart LR
user([User]) --> app[Host app + AppKit]
app -- HTTPS --> gh[(GitHub Releases)]
app --> settings[(settings.json)]
app --> logs[(logs/*.log)]
app --> vault[(Credential Manager)]
app -. opens .-> browser[Default browser]
subgraph machine [Trust boundary: the user's Windows session]
app
settings
logs
vault
end
```
## 4. External interfaces
| Interface | Direction | Data | Authenticated? | Validated where? |
| --- | --- | --- | --- | --- |
| Update check and download (`VelopackUpdateService`) | In | Release metadata, update packages | HTTPS to github.com; Velopack verifies package hashes from the release feed | Velopack; versions parsed by Velopack |
| `settings.json` (`SettingsStore<T>`) | In and out | User preferences | No (user-writable file) | `ISanitizable.Sanitize` clamps and validates every value on load; a corrupt file yields defaults |
| Log files (`FileActivityLog`) | Out (and in for `LogTail`) | Operational messages | No (user-writable files) | Viewers display text only; nothing is executed or parsed beyond line timestamps (regex with a timeout) |
| Credential Manager (`CredentialManagerVault`) | In and out | Secrets the host app stores (tokens) | Windows logon session (DPAPI) | Keys validated; secrets never logged |
| `<ID>_DATA_DIR` environment variable (`AppPaths`) | In | Data folder path | No | Used as a folder path only |
| Velopack process hooks (`VelopackStartup`) | In | Command-line arguments from the installer/updater | No | Velopack handles them and exits; other arguments pass through untouched |
| Links in the About dialog and log window | Out | Repository URL, log folder path | No | Values come from the host app's `AppInfo`, not from input |
## 5. Assets
- The user's secrets in the Credential Manager (for example GitHub tokens in gclo).
- The integrity of the host app's code: a malicious update would run as the user.
- The user's data and preferences under `%LOCALAPPDATA%\<id>`.
- The availability of the host app (a crash or hang at startup).
## 6. Threats (STRIDE)
| # | Boundary / component | Category | Threat | Likelihood | Impact | Mitigation | Status |
| --- | --- | --- | --- | --- | --- | --- | --- |
| T1 | Update feed | Tampering | An attacker serves a malicious update package | Low | Critical | HTTPS only; Velopack verifies package hashes; releases are immutable and every asset is attested by the shared release workflow (SLSA Build L3); prerelease builds follow only the dev channel | Mitigated |
| T2 | Update feed | Spoofing | A look-alike repository URL is configured | Low | Critical | The feed is the host app's own `AppInfo.RepositoryUrl`, fixed at build time, not user input | Mitigated |
| T3 | Update flow | Denial of service | A failing or slow update check crashes or blocks the app | Medium | Medium | Every update member is guarded and returns errors as values; the check runs after startup | Mitigated |
| T4 | Secrets | Information disclosure | A token leaks through logs, settings, or memory | Medium | High | Secrets only in the Credential Manager; never in settings or logs; the pinned plaintext buffer is cleared after writing | Mitigated |
| T5 | settings.json | Tampering | A crafted settings file sets out-of-range values or crashes parsing | Low | Low | Source-generated JSON parsing inside a catch-all; values sanitized; failure means defaults | Mitigated |
| T6 | Log files | Tampering / denial of service | A huge or crafted log file hangs the log viewer | Low | Low | The viewer reads only the last 2,000 lines; the entry regex has a match timeout | Mitigated |
| T7 | UI thread | Denial of service | An exception escaping an async handler kills the app | Medium | Medium | Crash net logs and handles dispatcher exceptions; one-dialog-at-a-time guard prevents the stowed-exception crash | Mitigated |
| T8 | Data folder | Elevation of privilege | `<ID>_DATA_DIR` points the app at another location | Low | Low | The variable is set by the same user; the app gains no rights it did not have | Accepted |
| T9 | Dependencies | Tampering | A compromised package in AppKit's graph | Low | High | Central versions, lock files with locked restore, dependency review, Trivy and OSV-Scanner in CI, Dependabot with a cooldown | Mitigated |
| T10 | Repudiation | Repudiation | Not applicable: AppKit makes no security decisions on behalf of a remote party | n/a | n/a | n/a | n/a |
## 7. Accepted risks
- **T8:** another process running as the same user can redirect or read AppKit's data;
defending against the user's own processes is outside a desktop library's reach.
- **Unsigned binaries until code signing is available:** apps built on AppKit ship
without an Authenticode signature until the KofTwentyTwo signing identity is validated
([exception EX-0002](https://github.com/KofTwentyTwo/standards/blob/main/exceptions/register.md#ex-0002));
provenance attestations and checksums remain verifiable.
## 8. Review log
| Date | Version | Reviewer | Changes |
| --- | --- | --- | --- |
| 2026-10-09 | 0.1.0-dev | James Maes | Initial model |
+10 -10
View File
@@ -1,13 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<Application
x:Class="AppKit.Sample.WinUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
x:Class="AppKit.Sample.WinUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
+29 -16
View File
@@ -1,28 +1,41 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Logging;
using KofTwentyTwo.AppKit.WinUI;
using Microsoft.UI.Xaml;
namespace AppKit.Sample.WinUI;
/// <summary>The WinUI sample application: installs AppKit&apos;s crash net and the saved theme, then opens the main window.</summary>
public partial class App : Application
{
private Window? _window;
private Window? _window;
/// <summary>The app-wide activity log.</summary>
internal static IActivityLog Log { get; } = FileActivityLog.ForApp(SampleApp.Info, SampleApp.Paths);
/// <summary>The app-wide activity log.</summary>
internal static IActivityLog Log { get; } = FileActivityLog.ForApp(SampleApp.Info, SampleApp.Paths);
public App()
{
InitializeComponent();
WinUIShell.InstallCrashNet(this, () => Log);
// Only settable here, before any window exists.
WinUIShell.ApplyTheme(this, SampleApp.SettingsStore.Load().ThemeKind);
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
Log.Info($"{SampleApp.Info.DisplayName} started.");
_window = new MainWindow();
_window.Activate();
}
/// <summary>Installs the crash net and applies the saved theme; WinUI allows setting the application theme only here, before any window exists.</summary>
public App()
{
InitializeComponent();
WinUIShell.InstallCrashNet(this, () => Log);
// Only settable here, before any window exists.
WinUIShell.ApplyTheme(this, SampleApp.SettingsStore.Load().ThemeKind);
}
/// <summary>Logs the start and opens the main window.</summary>
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
Log.Info($"{SampleApp.Info.DisplayName} started.");
_window = new MainWindow();
_window.Activate();
}
}
@@ -1,38 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Unpackaged, self-contained WinUI 3 app: the shape Velopack ships. The sample is
the runtime proof for the WinUI package and the UI smoke tests' target. -->
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<RootNamespace>AppKit.Sample.WinUI</RootNamespace>
<AssemblyName>AppKitSample.WinUI</AssemblyName>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>..\Shared\Brand\app.ico</ApplicationIcon>
<Platforms>x86;x64;ARM64</Platforms>
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
<UseWinUI>true</UseWinUI>
<WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<!-- Velopack must run before any XAML starts, so the app supplies its own Main (Program.cs). -->
<DefineConstants>$(DefineConstants);DISABLE_XAML_GENERATED_MAIN</DefineConstants>
</PropertyGroup>
<!-- Unpackaged, self-contained WinUI 3 app: the shape Velopack ships. The sample is
the runtime proof for the WinUI package and the UI smoke tests' target. -->
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<RootNamespace>AppKit.Sample.WinUI</RootNamespace>
<AssemblyName>AppKitSample.WinUI</AssemblyName>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>..\Shared\Brand\app.ico</ApplicationIcon>
<Platforms>x86;x64;ARM64</Platforms>
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
<UseWinUI>true</UseWinUI>
<WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<!-- Velopack must run before any XAML starts, so the app supplies its own Main (Program.cs). -->
<DefineConstants>$(DefineConstants);DISABLE_XAML_GENERATED_MAIN</DefineConstants>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\Shared\SampleApp.cs" Link="SampleApp.cs" />
<Content Include="..\Shared\Brand\app-icon.svg" Link="Assets\Brand\app-icon.svg" CopyToOutputDirectory="PreserveNewest" />
<Content Include="..\Shared\Brand\app.ico" Link="Assets\Brand\app.ico" CopyToOutputDirectory="PreserveNewest" />
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\Shared\*.cs" Link="Shared\%(Filename)%(Extension)" />
<Content Include="..\Shared\Brand\app-icon.svg" Link="Assets\Brand\app-icon.svg" CopyToOutputDirectory="PreserveNewest" />
<Content Include="..\Shared\Brand\app.ico" Link="Assets\Brand\app.ico" CopyToOutputDirectory="PreserveNewest" />
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\KofTwentyTwo.AppKit.WinUI\KofTwentyTwo.AppKit.WinUI.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\KofTwentyTwo.AppKit.WinUI\KofTwentyTwo.AppKit.WinUI.csproj" />
</ItemGroup>
</Project>
+40 -40
View File
@@ -1,47 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<Window
x:Class="AppKit.Sample.WinUI.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
x:Class="AppKit.Sample.WinUI.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Window.SystemBackdrop>
<MicaBackdrop />
</Window.SystemBackdrop>
<Window.SystemBackdrop>
<MicaBackdrop />
</Window.SystemBackdrop>
<Grid x:Name="RootGrid">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid x:Name="RootGrid">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<MenuBar>
<MenuBarItem Title="File">
<MenuFlyoutItem Text="Exit" Click="Exit_Click" />
</MenuBarItem>
<MenuBarItem Title="View">
<RadioMenuFlyoutItem x:Name="ThemeSystem" Text="System theme" GroupName="Theme" Tag="System" Click="Theme_Click" />
<RadioMenuFlyoutItem x:Name="ThemeLight" Text="Light theme" GroupName="Theme" Tag="Light" Click="Theme_Click" />
<RadioMenuFlyoutItem x:Name="ThemeDark" Text="Dark theme" GroupName="Theme" Tag="Dark" Click="Theme_Click" />
<MenuFlyoutSeparator />
<ToggleMenuFlyoutItem x:Name="SplashToggle" Text="Show splash at startup" Click="Splash_Click" />
</MenuBarItem>
<MenuBarItem Title="Help">
<MenuFlyoutItem x:Name="CheckForUpdatesMenuItem" Text="Check for updates" Click="CheckForUpdates_Click" />
<MenuFlyoutItem x:Name="ActivityLogMenuItem" Text="Activity log" Click="ActivityLog_Click" />
<MenuFlyoutSeparator />
<MenuFlyoutItem x:Name="AboutMenuItem" Text="About AppKit Sample" Click="About_Click" />
</MenuBarItem>
</MenuBar>
<MenuBar>
<MenuBarItem Title="File">
<MenuFlyoutItem Text="Exit" Click="Exit_Click" />
</MenuBarItem>
<MenuBarItem Title="View">
<RadioMenuFlyoutItem x:Name="ThemeSystem" Text="System theme" GroupName="Theme" Tag="System" Click="Theme_Click" />
<RadioMenuFlyoutItem x:Name="ThemeLight" Text="Light theme" GroupName="Theme" Tag="Light" Click="Theme_Click" />
<RadioMenuFlyoutItem x:Name="ThemeDark" Text="Dark theme" GroupName="Theme" Tag="Dark" Click="Theme_Click" />
<MenuFlyoutSeparator />
<ToggleMenuFlyoutItem x:Name="SplashToggle" Text="Show splash at startup" Click="Splash_Click" />
</MenuBarItem>
<MenuBarItem Title="Help">
<MenuFlyoutItem x:Name="CheckForUpdatesMenuItem" Text="Check for updates" Click="CheckForUpdates_Click" />
<MenuFlyoutItem x:Name="ActivityLogMenuItem" Text="Activity log" Click="ActivityLog_Click" />
<MenuFlyoutSeparator />
<MenuFlyoutItem x:Name="AboutMenuItem" Text="About AppKit Sample" Click="About_Click" />
</MenuBarItem>
</MenuBar>
<StackPanel Grid.Row="1" Spacing="12" Padding="32" MaxWidth="560" HorizontalAlignment="Left">
<TextBlock x:Name="GreetingText" Style="{ThemeResource TitleTextBlockStyle}" />
<TextBlock TextWrapping="Wrap" Text="Each control below exercises one AppKit service. Settings persist to %LOCALAPPDATA%\appkit-sample\settings.json; the log lives beside it." />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Write a log entry" Click="LogInfo_Click" />
<Button Content="Log a handled error" Click="LogError_Click" />
<Button x:Name="ThrowButton" Content="Throw unhandled" Click="Throw_Click" />
</StackPanel>
<TextBlock x:Name="StatusText" Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</StackPanel>
</Grid>
<StackPanel Grid.Row="1" Spacing="12" Padding="32" MaxWidth="560" HorizontalAlignment="Left">
<TextBlock x:Name="GreetingText" Style="{ThemeResource TitleTextBlockStyle}" />
<TextBlock TextWrapping="Wrap" Text="Each control below exercises one AppKit service. Settings persist to %LOCALAPPDATA%\appkit-sample\settings.json; the log lives beside it." />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Write a log entry" Click="LogInfo_Click" />
<Button Content="Log a handled error" Click="LogError_Click" />
<Button x:Name="ThrowButton" Content="Throw unhandled" Click="Throw_Click" />
</StackPanel>
<TextBlock x:Name="StatusText" Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</StackPanel>
</Grid>
</Window>
+121 -73
View File
@@ -1,98 +1,146 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Settings;
using KofTwentyTwo.AppKit.Updates;
using KofTwentyTwo.AppKit.WinUI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace AppKit.Sample.WinUI;
/// <summary>The sample&apos;s main window: each control exercises one AppKit service.</summary>
public sealed partial class MainWindow : Window
{
private readonly SampleSettings _settings = SampleApp.SettingsStore.Load();
private readonly UpdateCoordinator _updates;
private readonly SampleSettings _settings = SampleApp.SettingsStore.Load();
private readonly UpdateCoordinator _updates;
public MainWindow()
{
InitializeComponent();
Title = SampleApp.Info.DisplayName;
this.ResizeForDpi(900, 600);
this.SetMinimumSize(640, 420);
this.SetAppIcon(SampleApp.Info);
_updates = new UpdateCoordinator(
new VelopackUpdateService(SampleApp.Info.RepositoryUrl!),
new ContentDialogPrompter(() => Content.XamlRoot),
SampleApp.Info.DisplayName,
App.Log);
GreetingText.Text = _settings.Greeting;
SplashToggle.IsChecked = _settings.ShowSplashScreen;
(_settings.ThemeKind switch { AppTheme.Light => ThemeLight, AppTheme.Dark => ThemeDark, _ => ThemeSystem }).IsChecked = true;
/// <summary>Sizes and brands the window, wires the update coordinator, restores the saved settings, shows the splash, and schedules the quiet startup update check.</summary>
public MainWindow()
{
InitializeComponent();
Title = SampleApp.Info.DisplayName;
this.ResizeForDpi(900, 600);
this.SetMinimumSize(640, 420);
this.SetAppIcon(SampleApp.Info);
SplashOverlay.ShowOver(RootGrid, SampleApp.Info, _settings);
Closed += (_, _) => LogWindow.CloseCurrent(); // a log-only process would linger otherwise
_updates = new UpdateCoordinator(
new VelopackUpdateService(SampleApp.Info.RepositoryUrl!),
new ContentDialogPrompter(() => Content.XamlRoot),
SampleApp.Info.DisplayName,
App.Log);
if (_settings.CheckForUpdatesOnStartup)
{
DispatcherQueue.TryEnqueue(async () =>
{
await Task.Delay(_settings.ShowSplashScreen ? _settings.SplashMilliseconds + 500 : 1000);
await _updates.CheckQuietlyAsync();
});
}
}
GreetingText.Text = _settings.Greeting;
SplashToggle.IsChecked = _settings.ShowSplashScreen;
(_settings.ThemeKind switch { AppTheme.Light => ThemeLight, AppTheme.Dark => ThemeDark, _ => ThemeSystem }).IsChecked = true;
private void Exit_Click(object sender, RoutedEventArgs e) => Close();
SplashOverlay.ShowOver(RootGrid, SampleApp.Info, _settings);
Closed += (_, _) => LogWindow.CloseCurrent(); // a log-only process would linger otherwise
private void Theme_Click(object sender, RoutedEventArgs e)
{
_settings.Theme = (string)((FrameworkElement)sender).Tag;
WinUIShell.ApplyTheme(RootGrid, _settings.ThemeKind);
Save();
}
if(_settings.CheckForUpdatesOnStartup)
{
// The constructor runs on the UI thread, so the check resumes there to show dialogs.
_ = CheckForUpdatesAfterStartupAsync();
}
}
private void Splash_Click(object sender, RoutedEventArgs e)
{
_settings.ShowSplashScreen = SplashToggle.IsChecked;
Save();
}
private async void CheckForUpdates_Click(object sender, RoutedEventArgs e) => await _updates.CheckInteractivelyAsync();
private void ActivityLog_Click(object sender, RoutedEventArgs e) => LogWindow.ShowSingle(App.Log, SampleApp.Info);
/// <summary>
/// Waits for the splash to clear (dialogs need the window's XamlRoot), then runs the
/// quiet startup update check, which only speaks up when an update exists.
/// </summary>
private async Task CheckForUpdatesAfterStartupAsync()
{
await Task.Delay(_settings.ShowSplashScreen ? _settings.SplashMilliseconds + 500 : 1000);
await _updates.CheckQuietlyAsync();
}
private async void About_Click(object sender, RoutedEventArgs e) => await AboutDialog.ShowAsync(SampleApp.Info, Content.XamlRoot);
private void LogInfo_Click(object sender, RoutedEventArgs e)
{
App.Log.Info("The user pressed 'Write a log entry'.");
StatusText.Text = "Wrote an INFO entry.";
}
private void LogError_Click(object sender, RoutedEventArgs e)
{
try
{
throw new InvalidOperationException("A deliberately handled sample failure.");
}
catch (InvalidOperationException ex)
{
App.Log.Error("Sample operation failed.", ex);
StatusText.Text = "Wrote an ERROR entry with its stack trace.";
}
}
/// <summary>File, Exit.</summary>
private void Exit_Click(object sender, RoutedEventArgs e) => Close();
private void Throw_Click(object sender, RoutedEventArgs e)
{
StatusText.Text = "Threw; the crash net logged it and the app kept running.";
throw new InvalidOperationException("Deliberately unhandled sample exception.");
}
private void Save()
{
if (!SampleApp.SettingsStore.Save(_settings))
{
StatusText.Text = "Could not save settings.";
}
}
/// <summary>View, theme: applies the chosen theme to the window and saves it.</summary>
private void Theme_Click(object sender, RoutedEventArgs e)
{
_settings.Theme = (string)((FrameworkElement)sender).Tag;
WinUIShell.ApplyTheme(RootGrid, _settings.ThemeKind);
Save();
}
/// <summary>View, Show splash at startup: saves the preference.</summary>
private void Splash_Click(object sender, RoutedEventArgs e)
{
_settings.ShowSplashScreen = SplashToggle.IsChecked;
Save();
}
/// <summary>Help, Check for updates: runs the interactive update flow.</summary>
private async void CheckForUpdates_Click(object sender, RoutedEventArgs e) => await _updates.CheckInteractivelyAsync();
/// <summary>Help, Activity log: opens the live log window, or brings the open one forward.</summary>
private void ActivityLog_Click(object sender, RoutedEventArgs e) => LogWindow.ShowSingle(App.Log, SampleApp.Info);
/// <summary>Help, About: shows the About dialog.</summary>
private async void About_Click(object sender, RoutedEventArgs e) => await AboutDialog.ShowAsync(SampleApp.Info, Content.XamlRoot);
/// <summary>Writes an INFO entry, to show logging.</summary>
private void LogInfo_Click(object sender, RoutedEventArgs e)
{
App.Log.Info("The user pressed 'Write a log entry'.");
StatusText.Text = "Wrote an INFO entry.";
}
/// <summary>Logs a handled exception with its stack trace, to show error logging.</summary>
private void LogError_Click(object sender, RoutedEventArgs e)
{
try
{
throw new InvalidOperationException("A deliberately handled sample failure.");
}
catch(InvalidOperationException ex)
{
App.Log.Error("Sample operation failed.", ex);
StatusText.Text = "Wrote an ERROR entry with its stack trace.";
}
}
/// <summary>Throws on purpose, to show that the crash net logs the exception and the app keeps running.</summary>
private void Throw_Click(object sender, RoutedEventArgs e)
{
StatusText.Text = "Threw; the crash net logged it and the app kept running.";
throw new InvalidOperationException("Deliberately unhandled sample exception.");
}
/// <summary>Saves the settings and reports a failed save in the status line.</summary>
private void Save()
{
if(!SampleApp.SettingsStore.Save(_settings))
{
StatusText.Text = "Could not save settings.";
}
}
}
+19 -12
View File
@@ -1,6 +1,12 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Updates;
using Microsoft.UI.Dispatching;
namespace AppKit.Sample.WinUI;
/// <summary>
@@ -9,17 +15,18 @@ namespace AppKit.Sample.WinUI;
/// </summary>
public static class Program
{
[STAThread]
private static void Main()
{
VelopackStartup.Run();
/// <summary>Runs Velopack&apos;s install and update hooks first, then starts the WinUI application (the XAML-generated Main is disabled for this).</summary>
[STAThread]
private static void Main()
{
VelopackStartup.Run();
// The remainder mirrors the XAML-generated Main.
WinRT.ComWrappersSupport.InitializeComWrappers();
Microsoft.UI.Xaml.Application.Start(callbackParams =>
{
SynchronizationContext.SetSynchronizationContext(new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread()));
_ = new App();
});
}
// The remainder mirrors the XAML-generated Main.
WinRT.ComWrappersSupport.InitializeComWrappers();
Microsoft.UI.Xaml.Application.Start(callbackParams =>
{
SynchronizationContext.SetSynchronizationContext(new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread()));
_ = new App();
});
}
}
+12 -12
View File
@@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="AppKitSample.WinUI.app"/>
<assemblyIdentity version="1.0.0.0" name="AppKitSample.WinUI.app"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10/11: required for features in unpackaged apps such as the custom title bar. -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10/11: required for features in unpackaged apps such as the custom title bar. -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>
@@ -0,0 +1,246 @@
{
"version": 2,
"dependencies": {
"net10.0-windows10.0.19041": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.296, )",
"resolved": "3.0.296",
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
},
"Microsoft.Windows.SDK.BuildTools": {
"type": "Direct",
"requested": "[10.0.28000.2705, )",
"resolved": "10.0.28000.2705",
"contentHash": "TZQ96xtu7+fyFsyJCgj6Hazru0H8G1kiE1CeZLGpsqT4xKQmkxWu9yRuxYLeHJECjqEHN7duZUTcdbt8zlQ8OQ=="
},
"Microsoft.WindowsAppSDK": {
"type": "Direct",
"requested": "[2.5.1, )",
"resolved": "2.5.1",
"contentHash": "WgKPzT1H2s17azODD1jNZje69z1hGz8VSh3bIylTtXHVg7hLyrn8Y+rswFqF41UX8ea7ELeoyqN80zZ6EHwCFA==",
"dependencies": {
"Microsoft.WindowsAppSDK.AI": "2.5.5",
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.DWrite": "2.1.0",
"Microsoft.WindowsAppSDK.Foundation": "2.3.12",
"Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.9",
"Microsoft.WindowsAppSDK.ML": "2.1.94",
"Microsoft.WindowsAppSDK.Runtime": "[2.5.1]",
"Microsoft.WindowsAppSDK.Search": "2.5.5",
"Microsoft.WindowsAppSDK.Widgets": "2.0.5",
"Microsoft.WindowsAppSDK.WinUI": "2.3.9"
}
},
"Microsoft.Web.WebView2": {
"type": "Transitive",
"resolved": "1.0.3719.77",
"contentHash": "t+ucyKw5NTwMjsUrDF6R9Lk40lpcKQD1/HgyGFxl49tdA4h9dKlsj6FYGEmDRpFNfnTpENuTypMcdbrlkqBdDA=="
},
"Microsoft.Windows.AI.MachineLearning": {
"type": "Transitive",
"resolved": "2.1.74",
"contentHash": "/Z1yAQ0J53eD1RrOl5anTDS0UQOgneERY//hLljwGcWeUX9ynCvOl5qUxAsfWvwsZZGcvV4WKjAvnAtA6N5i+w==",
"dependencies": {
"System.Numerics.Tensors": "9.0.0"
}
},
"Microsoft.Windows.SDK.BuildTools.MSIX": {
"type": "Transitive",
"resolved": "1.7.251221100",
"contentHash": "f4aIZJ0NUth2403oxrpR+9rxVzZVI6dabqB21u8ncnk8eJAKCs9m77E4iYAnvP1YwrRe4axz3f8+yUcttNSfEA=="
},
"Microsoft.WindowsAppSDK.AI": {
"type": "Transitive",
"resolved": "2.5.5",
"contentHash": "f6XA05BsTAmDDsWGiFQdcAujNlbET5QbqxhBo7iHPJJix+RJajXKv1XlO19M78GtEpi2AYGEv08dPxER8ESYAw==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.Foundation": "2.3.12"
}
},
"Microsoft.WindowsAppSDK.Base": {
"type": "Transitive",
"resolved": "2.0.4",
"contentHash": "QXSy2llX2/Bx9dYWGUEsb10F40q94ZiDnPMzqa2/qRmTG4y9EXxGp6uHITb7c0b4FCa+6KFBlgh6D53M0QEMEg==",
"dependencies": {
"Microsoft.Windows.SDK.BuildTools": "10.0.26100.4654",
"Microsoft.Windows.SDK.BuildTools.MSIX": "1.7.251221100"
}
},
"Microsoft.WindowsAppSDK.DWrite": {
"type": "Transitive",
"resolved": "2.1.0",
"contentHash": "dgix7NSeo7Z8SZPyrsOqYm/6OUvBveHCiVyorYecmtDYoZW26dJ6/i9yveIxgWvmFpgfKXPeIuQBCvhbetHiYg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.3"
}
},
"Microsoft.WindowsAppSDK.Foundation": {
"type": "Transitive",
"resolved": "2.3.12",
"contentHash": "UflrNbXoZmHPt8lLGDk4gQ+yoGfQlCHd3TVTy7YwCMZ/aVGWAiLDqGCEjtWmNsI8ySy6imx3ZlenKfd2/VMPEg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.8"
}
},
"Microsoft.WindowsAppSDK.InteractiveExperiences": {
"type": "Transitive",
"resolved": "2.1.9",
"contentHash": "59nvb3pQYsBTTGrobeHkB4bkxASHtpaORJl7r6Zvg8PSIDCupEZTPKrIhnUknHDGfAtuBsnORjgvRi2WXGaRhA==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4"
}
},
"Microsoft.WindowsAppSDK.ML": {
"type": "Transitive",
"resolved": "2.1.94",
"contentHash": "b21CWVubc3WqWNJ9+fy7n+s83LgwBpIzYrOuSUmHAqPB0pH+ttjgiknnDA/oF7wCbjiaUb4KDXVV7Mya3f18Ug==",
"dependencies": {
"Microsoft.Windows.AI.MachineLearning": "[2.1.74, 3.0.0)",
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.Foundation": "2.3.12"
}
},
"Microsoft.WindowsAppSDK.Runtime": {
"type": "Transitive",
"resolved": "2.5.1",
"contentHash": "EDzGf6Tq9njNfNsTfqKdiwmSzFF/caHItuDa99UTQiOvLXwOjCL1k75/PMLSoHbCo1cSlHh/sSqZVssJ/Z0NzA==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4"
}
},
"Microsoft.WindowsAppSDK.Search": {
"type": "Transitive",
"resolved": "2.5.5",
"contentHash": "uvYkXi9TB4rk/kgWn5XHEkMNMU+xhjY+S0QWsxEs8wYT/dXAjfHvW30+JVoW/THZ/9jIMinEn4gliCSVRFV0Sw==",
"dependencies": {
"Microsoft.WindowsAppSDK.AI": "[2.5.5]",
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.Foundation": "2.3.12"
}
},
"Microsoft.WindowsAppSDK.Widgets": {
"type": "Transitive",
"resolved": "2.0.5",
"contentHash": "lRz+8+QU65gV8hRzLVE+GRsPKZ42lnzqkmsw96HzQd/DuFYfr4JYpUU7ZZ5YrkV2EfQe4BCmoRKAeZ3WwalMcg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.3"
}
},
"Microsoft.WindowsAppSDK.WinUI": {
"type": "Transitive",
"resolved": "2.3.9",
"contentHash": "GiuYUQqMYD5lycT+X2AGX1rlbHU/zf8xoPeZNXZbJj4mZcSr4STsxeRUMxnTUQLAmmjjQaPrTIRjn78vbPhAvg==",
"dependencies": {
"Microsoft.Web.WebView2": "1.0.3719.77",
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.Foundation": "2.3.12",
"Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.8"
}
},
"System.Numerics.Tensors": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "hyJB4UlpAi19Xr9AXzu2NuagKC4lPfHObNMEAA0HmqFz2rX7wKgzeYzO/jM/eBHDhnUGFFEjk5cOoJaxqg5J4A=="
},
"koftwentytwo.appkit": {
"type": "Project"
},
"koftwentytwo.appkit.updates": {
"type": "Project",
"dependencies": {
"KofTwentyTwo.AppKit": "[0.1.0-dev, )",
"Velopack": "[1.2.161, )"
}
},
"koftwentytwo.appkit.winui": {
"type": "Project",
"dependencies": {
"KofTwentyTwo.AppKit": "[0.1.0-dev, )",
"KofTwentyTwo.AppKit.Updates": "[0.1.0-dev, )",
"Microsoft.WindowsAppSDK": "[2.5.1, )"
}
},
"Velopack": {
"type": "CentralTransitive",
"requested": "[1.2.161, )",
"resolved": "1.2.161",
"contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
}
},
"net10.0-windows10.0.19041/win-arm64": {
"Microsoft.Web.WebView2": {
"type": "Transitive",
"resolved": "1.0.3719.77",
"contentHash": "t+ucyKw5NTwMjsUrDF6R9Lk40lpcKQD1/HgyGFxl49tdA4h9dKlsj6FYGEmDRpFNfnTpENuTypMcdbrlkqBdDA=="
},
"Microsoft.Windows.AI.MachineLearning": {
"type": "Transitive",
"resolved": "2.1.74",
"contentHash": "/Z1yAQ0J53eD1RrOl5anTDS0UQOgneERY//hLljwGcWeUX9ynCvOl5qUxAsfWvwsZZGcvV4WKjAvnAtA6N5i+w==",
"dependencies": {
"System.Numerics.Tensors": "9.0.0"
}
},
"Microsoft.WindowsAppSDK.Foundation": {
"type": "Transitive",
"resolved": "2.3.12",
"contentHash": "UflrNbXoZmHPt8lLGDk4gQ+yoGfQlCHd3TVTy7YwCMZ/aVGWAiLDqGCEjtWmNsI8ySy6imx3ZlenKfd2/VMPEg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.8"
}
}
},
"net10.0-windows10.0.19041/win-x64": {
"Microsoft.Web.WebView2": {
"type": "Transitive",
"resolved": "1.0.3719.77",
"contentHash": "t+ucyKw5NTwMjsUrDF6R9Lk40lpcKQD1/HgyGFxl49tdA4h9dKlsj6FYGEmDRpFNfnTpENuTypMcdbrlkqBdDA=="
},
"Microsoft.Windows.AI.MachineLearning": {
"type": "Transitive",
"resolved": "2.1.74",
"contentHash": "/Z1yAQ0J53eD1RrOl5anTDS0UQOgneERY//hLljwGcWeUX9ynCvOl5qUxAsfWvwsZZGcvV4WKjAvnAtA6N5i+w==",
"dependencies": {
"System.Numerics.Tensors": "9.0.0"
}
},
"Microsoft.WindowsAppSDK.Foundation": {
"type": "Transitive",
"resolved": "2.3.12",
"contentHash": "UflrNbXoZmHPt8lLGDk4gQ+yoGfQlCHd3TVTy7YwCMZ/aVGWAiLDqGCEjtWmNsI8ySy6imx3ZlenKfd2/VMPEg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.8"
}
}
},
"net10.0-windows10.0.19041/win-x86": {
"Microsoft.Web.WebView2": {
"type": "Transitive",
"resolved": "1.0.3719.77",
"contentHash": "t+ucyKw5NTwMjsUrDF6R9Lk40lpcKQD1/HgyGFxl49tdA4h9dKlsj6FYGEmDRpFNfnTpENuTypMcdbrlkqBdDA=="
},
"Microsoft.Windows.AI.MachineLearning": {
"type": "Transitive",
"resolved": "2.1.74",
"contentHash": "/Z1yAQ0J53eD1RrOl5anTDS0UQOgneERY//hLljwGcWeUX9ynCvOl5qUxAsfWvwsZZGcvV4WKjAvnAtA6N5i+w==",
"dependencies": {
"System.Numerics.Tensors": "9.0.0"
}
},
"Microsoft.WindowsAppSDK.Foundation": {
"type": "Transitive",
"resolved": "2.3.12",
"contentHash": "UflrNbXoZmHPt8lLGDk4gQ+yoGfQlCHd3TVTy7YwCMZ/aVGWAiLDqGCEjtWmNsI8ySy6imx3ZlenKfd2/VMPEg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.8"
}
}
}
}
}
@@ -1,26 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- WPF sample: the runtime proof for the WPF package. No App.xaml: Program.Main must
run Velopack's hooks before the Application exists, and the Fluent theme needs no
resource dictionary when set through Application.ThemeMode. -->
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<RootNamespace>AppKit.Sample.Wpf</RootNamespace>
<AssemblyName>AppKitSample.Wpf</AssemblyName>
<ApplicationIcon>..\Shared\Brand\app.ico</ApplicationIcon>
<StartupObject>AppKit.Sample.Wpf.Program</StartupObject>
<NoWarn>$(NoWarn);WPF0001</NoWarn>
</PropertyGroup>
<!-- WPF sample: the runtime proof for the WPF package. No App.xaml: Program.Main must
run Velopack's hooks before the Application exists, and the Fluent theme needs no
resource dictionary when set through Application.ThemeMode. -->
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<RootNamespace>AppKit.Sample.Wpf</RootNamespace>
<AssemblyName>AppKitSample.Wpf</AssemblyName>
<ApplicationIcon>..\Shared\Brand\app.ico</ApplicationIcon>
<StartupObject>AppKit.Sample.Wpf.Program</StartupObject>
<NoWarn>$(NoWarn);WPF0001</NoWarn>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\Shared\SampleApp.cs" Link="SampleApp.cs" />
<Content Include="..\Shared\Brand\app.ico" Link="Assets\Brand\app.ico" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\Shared\*.cs" Link="Shared\%(Filename)%(Extension)" />
<Content Include="..\Shared\Brand\app.ico" Link="Assets\Brand\app.ico" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\KofTwentyTwo.AppKit.Wpf\KofTwentyTwo.AppKit.Wpf.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\KofTwentyTwo.AppKit.Wpf\KofTwentyTwo.AppKit.Wpf.csproj" />
</ItemGroup>
</Project>
+41 -41
View File
@@ -1,46 +1,46 @@
<Window
x:Class="AppKit.Sample.Wpf.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Width="900"
Height="600"
MinWidth="640"
MinHeight="420">
x:Class="AppKit.Sample.Wpf.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Width="900"
Height="600"
MinWidth="640"
MinHeight="420">
<Grid x:Name="RootGrid">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid x:Name="RootGrid">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Menu>
<MenuItem Header="_File">
<MenuItem Header="E_xit" Click="Exit_Click" />
</MenuItem>
<MenuItem Header="_View">
<MenuItem x:Name="ThemeSystem" Header="System theme" Tag="System" IsCheckable="True" Click="Theme_Click" />
<MenuItem x:Name="ThemeLight" Header="Light theme" Tag="Light" IsCheckable="True" Click="Theme_Click" />
<MenuItem x:Name="ThemeDark" Header="Dark theme" Tag="Dark" IsCheckable="True" Click="Theme_Click" />
<Separator />
<MenuItem x:Name="SplashToggle" Header="Show splash at startup" IsCheckable="True" Click="Splash_Click" />
</MenuItem>
<MenuItem Header="_Help">
<MenuItem x:Name="CheckForUpdatesMenuItem" Header="Check for _updates" Click="CheckForUpdates_Click" />
<MenuItem x:Name="ActivityLogMenuItem" Header="Activity _log" Click="ActivityLog_Click" />
<Separator />
<MenuItem x:Name="AboutMenuItem" Header="_About AppKit Sample" Click="About_Click" />
</MenuItem>
</Menu>
<Menu>
<MenuItem Header="_File">
<MenuItem Header="E_xit" Click="Exit_Click" />
</MenuItem>
<MenuItem Header="_View">
<MenuItem x:Name="ThemeSystem" Header="System theme" Tag="System" IsCheckable="True" Click="Theme_Click" />
<MenuItem x:Name="ThemeLight" Header="Light theme" Tag="Light" IsCheckable="True" Click="Theme_Click" />
<MenuItem x:Name="ThemeDark" Header="Dark theme" Tag="Dark" IsCheckable="True" Click="Theme_Click" />
<Separator />
<MenuItem x:Name="SplashToggle" Header="Show splash at startup" IsCheckable="True" Click="Splash_Click" />
</MenuItem>
<MenuItem Header="_Help">
<MenuItem x:Name="CheckForUpdatesMenuItem" Header="Check for _updates" Click="CheckForUpdates_Click" />
<MenuItem x:Name="ActivityLogMenuItem" Header="Activity _log" Click="ActivityLog_Click" />
<Separator />
<MenuItem x:Name="AboutMenuItem" Header="_About AppKit Sample" Click="About_Click" />
</MenuItem>
</Menu>
<StackPanel Grid.Row="1" Margin="32" MaxWidth="560" HorizontalAlignment="Left">
<TextBlock x:Name="GreetingText" FontSize="28" FontWeight="SemiBold" />
<TextBlock Margin="0,12,0,0" TextWrapping="Wrap" Text="Each control below exercises one AppKit service. Settings persist to %LOCALAPPDATA%\appkit-sample\settings.json; the log lives beside it." />
<StackPanel Margin="0,12,0,0" Orientation="Horizontal">
<Button Content="Write a log entry" Click="LogInfo_Click" />
<Button Margin="8,0,0,0" Content="Log a handled error" Click="LogError_Click" />
<Button x:Name="ThrowButton" Margin="8,0,0,0" Content="Throw unhandled" Click="Throw_Click" />
</StackPanel>
<TextBlock x:Name="StatusText" Margin="0,12,0,0" Opacity="0.7" />
</StackPanel>
</Grid>
<StackPanel Grid.Row="1" Margin="32" MaxWidth="560" HorizontalAlignment="Left">
<TextBlock x:Name="GreetingText" FontSize="28" FontWeight="SemiBold" />
<TextBlock Margin="0,12,0,0" TextWrapping="Wrap" Text="Each control below exercises one AppKit service. Settings persist to %LOCALAPPDATA%\appkit-sample\settings.json; the log lives beside it." />
<StackPanel Margin="0,12,0,0" Orientation="Horizontal">
<Button Content="Write a log entry" Click="LogInfo_Click" />
<Button Margin="8,0,0,0" Content="Log a handled error" Click="LogError_Click" />
<Button x:Name="ThrowButton" Margin="8,0,0,0" Content="Throw unhandled" Click="Throw_Click" />
</StackPanel>
<TextBlock x:Name="StatusText" Margin="0,12,0,0" Opacity="0.7" />
</StackPanel>
</Grid>
</Window>
+121 -79
View File
@@ -1,105 +1,147 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Windows;
using System.Windows.Controls;
using KofTwentyTwo.AppKit.Settings;
using KofTwentyTwo.AppKit.Updates;
using KofTwentyTwo.AppKit.Wpf;
namespace AppKit.Sample.Wpf;
/// <summary>The sample&apos;s main window: each control exercises one AppKit service.</summary>
public partial class MainWindow : Window
{
private readonly SampleSettings _settings = SampleApp.SettingsStore.Load();
private readonly UpdateCoordinator _updates;
private readonly SampleSettings _settings = SampleApp.SettingsStore.Load();
private readonly UpdateCoordinator _updates;
public MainWindow()
{
InitializeComponent();
Title = SampleApp.Info.DisplayName;
this.SetAppIcon(SampleApp.Info);
_updates = new UpdateCoordinator(
new VelopackUpdateService(SampleApp.Info.RepositoryUrl!),
new MessageBoxPrompter(() => this),
SampleApp.Info.DisplayName,
Program.Log);
GreetingText.Text = _settings.Greeting;
SplashToggle.IsChecked = _settings.ShowSplashScreen;
CheckTheme();
/// <summary>Brands the window, wires the update coordinator, restores the saved settings, shows the splash, and schedules the quiet startup update check.</summary>
public MainWindow()
{
InitializeComponent();
Title = SampleApp.Info.DisplayName;
this.SetAppIcon(SampleApp.Info);
SplashOverlay.ShowOver(RootGrid, SampleApp.Info, _settings);
Closed += (_, _) => LogWindow.CloseCurrent();
_updates = new UpdateCoordinator(
new VelopackUpdateService(SampleApp.Info.RepositoryUrl!),
new MessageBoxPrompter(() => this),
SampleApp.Info.DisplayName,
Program.Log);
if (_settings.CheckForUpdatesOnStartup)
{
Loaded += async (_, _) =>
{
await Task.Delay(_settings.ShowSplashScreen ? _settings.SplashMilliseconds + 500 : 1000);
await _updates.CheckQuietlyAsync();
};
}
}
GreetingText.Text = _settings.Greeting;
SplashToggle.IsChecked = _settings.ShowSplashScreen;
CheckTheme();
private void Exit_Click(object sender, RoutedEventArgs e) => Close();
SplashOverlay.ShowOver(RootGrid, SampleApp.Info, _settings);
Closed += (_, _) => LogWindow.CloseCurrent();
private void Theme_Click(object sender, RoutedEventArgs e)
{
_settings.Theme = (string)((FrameworkElement)sender).Tag;
WpfShell.ApplyTheme(Application.Current, _settings.ThemeKind);
CheckTheme();
Save();
}
if(_settings.CheckForUpdatesOnStartup)
{
Loaded += async (_, _) =>
{
await Task.Delay(_settings.ShowSplashScreen ? _settings.SplashMilliseconds + 500 : 1000);
await _updates.CheckQuietlyAsync();
};
}
}
private void Splash_Click(object sender, RoutedEventArgs e)
{
_settings.ShowSplashScreen = SplashToggle.IsChecked;
Save();
}
private async void CheckForUpdates_Click(object sender, RoutedEventArgs e) => await _updates.CheckInteractivelyAsync();
private void ActivityLog_Click(object sender, RoutedEventArgs e) => LogWindow.ShowSingle(Program.Log, SampleApp.Info);
/// <summary>File, Exit.</summary>
private void Exit_Click(object sender, RoutedEventArgs e) => Close();
private void About_Click(object sender, RoutedEventArgs e) => AboutWindow.Show(SampleApp.Info, this);
private void LogInfo_Click(object sender, RoutedEventArgs e)
{
Program.Log.Info("The user pressed 'Write a log entry'.");
StatusText.Text = "Wrote an INFO entry.";
}
private void LogError_Click(object sender, RoutedEventArgs e)
{
try
{
throw new InvalidOperationException("A deliberately handled sample failure.");
}
catch (InvalidOperationException ex)
{
Program.Log.Error("Sample operation failed.", ex);
StatusText.Text = "Wrote an ERROR entry with its stack trace.";
}
}
/// <summary>View, theme: applies the chosen Fluent theme to the application and saves it.</summary>
private void Theme_Click(object sender, RoutedEventArgs e)
{
_settings.Theme = (string)((FrameworkElement)sender).Tag;
WpfShell.ApplyTheme(Application.Current, _settings.ThemeKind);
CheckTheme();
Save();
}
private void Throw_Click(object sender, RoutedEventArgs e)
{
StatusText.Text = "Threw; the crash net logged it and the app kept running.";
throw new InvalidOperationException("Deliberately unhandled sample exception.");
}
private void CheckTheme()
{
foreach (MenuItem item in new[] { ThemeSystem, ThemeLight, ThemeDark })
{
item.IsChecked = (string)item.Tag == _settings.Theme;
}
}
private void Save()
{
if (!SampleApp.SettingsStore.Save(_settings))
{
StatusText.Text = "Could not save settings.";
}
}
/// <summary>View, Show splash at startup: saves the preference.</summary>
private void Splash_Click(object sender, RoutedEventArgs e)
{
_settings.ShowSplashScreen = SplashToggle.IsChecked;
Save();
}
/// <summary>Help, Check for updates: runs the interactive update flow.</summary>
private async void CheckForUpdates_Click(object sender, RoutedEventArgs e) => await _updates.CheckInteractivelyAsync();
/// <summary>Help, Activity log: opens the live log window, or brings the open one forward.</summary>
private void ActivityLog_Click(object sender, RoutedEventArgs e) => LogWindow.ShowSingle(Program.Log, SampleApp.Info);
/// <summary>Help, About: shows the About window.</summary>
private void About_Click(object sender, RoutedEventArgs e) => AboutWindow.Show(SampleApp.Info, this);
/// <summary>Writes an INFO entry, to show logging.</summary>
private void LogInfo_Click(object sender, RoutedEventArgs e)
{
Program.Log.Info("The user pressed 'Write a log entry'.");
StatusText.Text = "Wrote an INFO entry.";
}
/// <summary>Logs a handled exception with its stack trace, to show error logging.</summary>
private void LogError_Click(object sender, RoutedEventArgs e)
{
try
{
throw new InvalidOperationException("A deliberately handled sample failure.");
}
catch(InvalidOperationException ex)
{
Program.Log.Error("Sample operation failed.", ex);
StatusText.Text = "Wrote an ERROR entry with its stack trace.";
}
}
/// <summary>Throws on purpose, to show that the crash net logs the exception and the app keeps running.</summary>
private void Throw_Click(object sender, RoutedEventArgs e)
{
StatusText.Text = "Threw; the crash net logged it and the app kept running.";
throw new InvalidOperationException("Deliberately unhandled sample exception.");
}
/// <summary>Checks the View menu item that matches the saved theme and clears the others.</summary>
private void CheckTheme()
{
foreach(MenuItem item in new[] { ThemeSystem, ThemeLight, ThemeDark })
{
item.IsChecked = string.Equals((string)item.Tag, _settings.Theme, StringComparison.Ordinal);
}
}
/// <summary>Saves the settings and reports a failed save in the status line.</summary>
private void Save()
{
if(!SampleApp.SettingsStore.Save(_settings))
{
StatusText.Text = "Could not save settings.";
}
}
}
+21 -12
View File
@@ -1,25 +1,34 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Windows;
using KofTwentyTwo.AppKit.Logging;
using KofTwentyTwo.AppKit.Updates;
using KofTwentyTwo.AppKit.Wpf;
namespace AppKit.Sample.Wpf;
/// <summary>Entry point: Velopack hooks first, then the WPF application.</summary>
public static class Program
{
/// <summary>The app-wide activity log.</summary>
internal static IActivityLog Log { get; } = FileActivityLog.ForApp(SampleApp.Info, SampleApp.Paths);
/// <summary>The app-wide activity log.</summary>
internal static IActivityLog Log { get; } = FileActivityLog.ForApp(SampleApp.Info, SampleApp.Paths);
[STAThread]
private static void Main()
{
VelopackStartup.Run();
var app = new Application { ShutdownMode = ShutdownMode.OnMainWindowClose };
WpfShell.InstallCrashNet(app, () => Log);
WpfShell.ApplyTheme(app, SampleApp.SettingsStore.Load().ThemeKind);
Log.Info($"{SampleApp.Info.DisplayName} (WPF) started.");
app.Run(new MainWindow());
}
/// <summary>Runs Velopack&apos;s install and update hooks first, then creates the WPF application with AppKit&apos;s crash net and theme and shows the main window.</summary>
[STAThread]
private static void Main()
{
VelopackStartup.Run();
var app = new Application { ShutdownMode = ShutdownMode.OnMainWindowClose };
WpfShell.InstallCrashNet(app, () => Log);
WpfShell.ApplyTheme(app, SampleApp.SettingsStore.Load().ThemeKind);
Log.Info($"{SampleApp.Info.DisplayName} (WPF) started.");
app.Run(new MainWindow());
}
}
@@ -0,0 +1,36 @@
{
"version": 2,
"dependencies": {
"net10.0-windows7.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.296, )",
"resolved": "3.0.296",
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
},
"koftwentytwo.appkit": {
"type": "Project"
},
"koftwentytwo.appkit.updates": {
"type": "Project",
"dependencies": {
"KofTwentyTwo.AppKit": "[0.1.0-dev, )",
"Velopack": "[1.2.161, )"
}
},
"koftwentytwo.appkit.wpf": {
"type": "Project",
"dependencies": {
"KofTwentyTwo.AppKit": "[0.1.0-dev, )",
"KofTwentyTwo.AppKit.Updates": "[0.1.0-dev, )"
}
},
"Velopack": {
"type": "CentralTransitive",
"requested": "[1.2.161, )",
"resolved": "1.2.161",
"contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
}
}
}
}
+27 -38
View File
@@ -1,51 +1,40 @@
using System.Text.Json.Serialization;
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit;
using KofTwentyTwo.AppKit.Settings;
namespace AppKit.Sample;
/// <summary>Identity and settings shared by both sample apps (linked into each project).</summary>
internal static class SampleApp
{
public static AppInfo Info { get; } = new()
{
Id = "appkit-sample",
DisplayName = "AppKit Sample",
Tagline = "Everything a KofTwentyTwo Windows app gets for free",
Description = "A minimal app wired to every AppKit service: splash, About, activity log, settings, theme, crash net, and Velopack self-update.",
RepositoryUrl = new Uri("https://github.com/KofTwentyTwo/AppKit"),
Author = "James Maes",
AuthorEmail = "james@kof22.com",
Website = new Uri("https://kof22.com"),
Copyright = "Copyright (c) 2026 James Maes",
Brand = new AppBrand { IconSvgPath = "Assets/Brand/app-icon.svg", IconIcoPath = "Assets/Brand/app.ico" },
Attributions =
[
new("KofTwentyTwo.AppKit", "MIT License"),
new("Velopack", "MIT License"),
new(".NET Runtime", "MIT License"),
],
};
public static AppInfo Info { get; } = new()
{
Id = "appkit-sample",
DisplayName = "AppKit Sample",
Tagline = "Everything a KofTwentyTwo Windows app gets for free",
Description = "A minimal app wired to every AppKit service: splash, About, activity log, settings, theme, crash net, and Velopack self-update.",
RepositoryUrl = new Uri("https://github.com/KofTwentyTwo/AppKit"),
Author = "James Maes",
AuthorEmail = "james@kof22.com",
Website = new Uri("https://kof22.com"),
Copyright = "Copyright (c) 2026 James Maes",
Brand = new AppBrand { IconSvgPath = "Assets/Brand/app-icon.svg", IconIcoPath = "Assets/Brand/app.ico" },
Attributions =
[
new("KofTwentyTwo.AppKit", "MIT License"),
new("Velopack", "MIT License"),
new(".NET Runtime", "MIT License"),
],
};
public static AppPaths Paths { get; } = new(Info);
public static SettingsStore<SampleSettings> SettingsStore { get; } = new(Paths.SettingsFile, SampleSettingsContext.Default.SampleSettings);
}
/// <summary>The sample's preferences: the shared shell settings plus one of its own.</summary>
internal sealed class SampleSettings : ShellSettings
{
public string Greeting { get; set; } = "Hello from AppKit";
public static AppPaths Paths { get; } = new(Info);
public override void Sanitize()
{
base.Sanitize();
Greeting ??= "Hello from AppKit";
}
}
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(SampleSettings))]
internal sealed partial class SampleSettingsContext : JsonSerializerContext
{
public static SettingsStore<SampleSettings> SettingsStore { get; } = new(Paths.SettingsFile, SampleSettingsContext.Default.SampleSettings);
}
+24
View File
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Settings;
namespace AppKit.Sample;
/// <summary>The sample's preferences: the shared shell settings plus one of its own.</summary>
internal sealed class SampleSettings : ShellSettings
{
public string Greeting { get; set; } = "Hello from AppKit";
/// <summary>Repairs the shared shell settings, then restores a missing greeting.</summary>
public override void Sanitize()
{
base.Sanitize();
Greeting ??= "Hello from AppKit";
}
}
+16
View File
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Text.Json.Serialization;
namespace AppKit.Sample;
/// <summary>Source-generated JSON serializer for the sample&apos;s settings, so trimmed builds need no reflection.</summary>
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(SampleSettings))]
internal sealed partial class SampleSettingsContext : JsonSerializerContext
{
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace KofTwentyTwo.AppKit.Updates;
/// <summary>
/// The narrow slice of Velopack the update service uses, so the guarding and state
/// logic is testable without an installed app or the network.
/// </summary>
internal interface IUpdateBackend
{
bool IsInstalled { get; }
string? CurrentVersion { get; }
/// <summary>The newer version found (and remembered for download), or null.</summary>
Task<string?> CheckAsync();
/// <summary>Downloads the remembered update.</summary>
Task DownloadAsync(Action<int>? progress, CancellationToken cancellationToken);
/// <summary>Applies the downloaded update and restarts; exits the process on success.</summary>
void ApplyAndRestart();
}
@@ -1,49 +1,38 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace KofTwentyTwo.AppKit.Updates;
/// <summary>
/// Result of an update check. <see cref="AvailableVersion"/> is the newer version
/// found, or null when already up to date; <see cref="Error"/> is non-null when the
/// check itself failed.
/// </summary>
public sealed record UpdateCheckResult(string? AvailableVersion, string? Error)
{
/// <summary>No newer version exists.</summary>
public static UpdateCheckResult UpToDate { get; } = new(null, null);
/// <summary>True when a newer version was found.</summary>
public bool IsUpdateAvailable => Error is null && AvailableVersion is not null;
/// <summary>A newer version was found.</summary>
public static UpdateCheckResult Available(string version) => new(version, null);
/// <summary>The check failed.</summary>
public static UpdateCheckResult Failed(string error) => new(null, error);
}
/// <summary>
/// Checks for and installs app updates. Every member is guarded: failures come back as
/// values, never exceptions, because nothing as optional as an update may crash the app.
/// </summary>
public interface IUpdateService
{
/// <summary>
/// True when this build can update itself (it was installed by Velopack). False
/// under the debugger, for loose builds, and for MSIX packages.
/// </summary>
bool IsSupported { get; }
/// <summary>
/// True when this build can update itself (it was installed by Velopack). False
/// under the debugger, for loose builds, and for MSIX packages.
/// </summary>
bool IsSupported { get; }
/// <summary>The installed version, or null outside installed builds.</summary>
string? CurrentVersion { get; }
/// <summary>The installed version, or null outside installed builds.</summary>
string? CurrentVersion { get; }
/// <summary>Looks for a newer version and remembers it for <see cref="DownloadAndApplyAsync"/>.</summary>
Task<UpdateCheckResult> CheckAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Downloads the update found by the last successful <see cref="CheckAsync"/>,
/// applies it, and restarts the app. On success this never returns (the process
/// exits to restart); otherwise it returns an error message.
/// </summary>
/// <param name="progress">Receives download progress, 0..100.</param>
/// <param name="cancellationToken">Cancels the download.</param>
Task<string?> DownloadAndApplyAsync(IProgress<int>? progress = null, CancellationToken cancellationToken = default);
/// <summary>Looks for a newer version and remembers it for <see cref="DownloadAndApplyAsync"/>.</summary>
Task<UpdateCheckResult> CheckAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Downloads the update found by the last successful <see cref="CheckAsync"/>,
/// applies it, and restarts the app. On success this never returns (the process
/// exits to restart); otherwise it returns an error message.
/// </summary>
/// <param name="progress">Receives download progress, 0..100.</param>
/// <param name="cancellationToken">Cancels the download.</param>
Task<string?> DownloadAndApplyAsync(IProgress<int>? progress = null, CancellationToken cancellationToken = default);
}
@@ -1,27 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>true</IsPackable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Description>Velopack self-update for KofTwentyTwo Windows apps: startup hook handling, a never-throwing update service fed by GitHub Releases with stable/dev channel selection, and the framework-free "check for updates" dialog flow.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>true</IsPackable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Description>Velopack self-update for KofTwentyTwo Windows apps: startup hook handling, a never-throwing update service fed by GitHub Releases with stable/dev channel selection, and the framework-free "check for updates" dialog flow.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="KofTwentyTwo.AppKit.Tests" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="KofTwentyTwo.AppKit.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Velopack" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Velopack" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\KofTwentyTwo.AppKit\KofTwentyTwo.AppKit.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\KofTwentyTwo.AppKit\KofTwentyTwo.AppKit.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace KofTwentyTwo.AppKit.Updates;
/// <summary>
/// Result of an update check. <see cref="AvailableVersion"/> is the newer version
/// found, or null when already up to date; <see cref="Error"/> is non-null when the
/// check itself failed.
/// </summary>
public sealed record UpdateCheckResult(string? AvailableVersion, string? Error)
{
/// <summary>No newer version exists.</summary>
public static UpdateCheckResult UpToDate { get; } = new(null, null);
/// <summary>True when a newer version was found.</summary>
public bool IsUpdateAvailable => Error is null && AvailableVersion is not null;
/// <summary>A newer version was found.</summary>
public static UpdateCheckResult Available(string version) => new(version, null);
/// <summary>The check failed.</summary>
public static UpdateCheckResult Failed(string error) => new(null, error);
}
@@ -1,41 +1,14 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Interaction;
using KofTwentyTwo.AppKit.Logging;
namespace KofTwentyTwo.AppKit.Updates;
/// <summary>User-facing text of the update flow, shared by every UI package.</summary>
public static class UpdateText
{
/// <summary>Title of the check dialogs.</summary>
public const string CheckTitle = "Check for updates";
/// <summary>Title of the confirmation dialog.</summary>
public const string AvailableTitle = "Update available";
/// <summary>Title shown when installing failed.</summary>
public const string FailedTitle = "Update failed";
/// <summary>Shown in builds that cannot self-update.</summary>
public const string NotSupported = "Updates are only available in installed builds.";
/// <summary>Confirm button.</summary>
public const string Confirm = "Update and restart";
/// <summary>Decline button.</summary>
public const string Decline = "Not now";
/// <summary>"You are up to date (v1.2.3)."</summary>
public static string UpToDate(string? currentVersion)
=> currentVersion is null ? "You are up to date." : $"You are up to date (v{currentVersion}).";
/// <summary>Explains a failed check.</summary>
public static string CheckFailed(string error) => $"Could not check for updates.\n{error}";
/// <summary>Offers the update.</summary>
public static string Available(string appName, string version)
=> $"{appName} v{version} is available. The app will restart to finish installing the update.";
}
/// <summary>
/// The "check for updates" flows, written once against <see cref="IUpdateService"/> and
/// <see cref="IUserPrompter"/> so WinUI and WPF apps behave identically and the logic is
@@ -43,98 +16,107 @@ public static class UpdateText
/// </summary>
public sealed class UpdateCoordinator
{
private readonly IUpdateService _updates;
private readonly IUserPrompter _prompter;
private readonly string _appName;
private readonly IActivityLog _log;
private readonly IUpdateService _updates;
private readonly IUserPrompter _prompter;
private readonly string _appName;
private readonly IActivityLog _log;
/// <summary>Creates the coordinator.</summary>
/// <param name="updates">The update service.</param>
/// <param name="prompter">The UI's dialog adapter.</param>
/// <param name="appName">Display name used in prompts.</param>
/// <param name="log">Activity log; discarded when null.</param>
public UpdateCoordinator(IUpdateService updates, IUserPrompter prompter, string appName, IActivityLog? log = null)
{
ArgumentNullException.ThrowIfNull(updates);
ArgumentNullException.ThrowIfNull(prompter);
ArgumentException.ThrowIfNullOrWhiteSpace(appName);
_updates = updates;
_prompter = prompter;
_appName = appName;
_log = log ?? NullActivityLog.Instance;
}
/// <summary>
/// The Help → Check for updates command: reports every outcome to the user, and
/// offers to install when a newer version exists.
/// </summary>
public async Task CheckInteractivelyAsync(CancellationToken cancellationToken = default)
{
if (!_updates.IsSupported)
{
await _prompter.ShowMessageAsync(UpdateText.CheckTitle, UpdateText.NotSupported).ConfigureAwait(true);
return;
}
UpdateCheckResult result = await _updates.CheckAsync(cancellationToken).ConfigureAwait(true);
if (result.Error is not null)
{
_log.Warning($"Update check failed: {result.Error}");
await _prompter.ShowMessageAsync(UpdateText.CheckTitle, UpdateText.CheckFailed(result.Error)).ConfigureAwait(true);
return;
}
/// <summary>Creates the coordinator.</summary>
/// <param name="updates">The update service.</param>
/// <param name="prompter">The UI's dialog adapter.</param>
/// <param name="appName">Display name used in prompts.</param>
/// <param name="log">Activity log; discarded when null.</param>
public UpdateCoordinator(IUpdateService updates, IUserPrompter prompter, string appName, IActivityLog? log = null)
{
ArgumentNullException.ThrowIfNull(updates);
ArgumentNullException.ThrowIfNull(prompter);
ArgumentException.ThrowIfNullOrWhiteSpace(appName);
_updates = updates;
_prompter = prompter;
_appName = appName;
_log = log ?? NullActivityLog.Instance;
}
if (result.AvailableVersion is null)
{
await _prompter.ShowMessageAsync(UpdateText.CheckTitle, UpdateText.UpToDate(_updates.CurrentVersion)).ConfigureAwait(true);
return;
}
await OfferAsync(result.AvailableVersion, cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// The startup check: silent unless a newer version exists, in which case the user
/// is offered the update. Unsupported builds and failures are only logged.
/// </summary>
public async Task CheckQuietlyAsync(CancellationToken cancellationToken = default)
{
if (!_updates.IsSupported)
{
return;
}
/// <summary>
/// The Help → Check for updates command: reports every outcome to the user, and
/// offers to install when a newer version exists.
/// </summary>
public async Task CheckInteractivelyAsync(CancellationToken cancellationToken = default)
{
if(!_updates.IsSupported)
{
await _prompter.ShowMessageAsync(UpdateText.CheckTitle, UpdateText.NotSupported).ConfigureAwait(true);
return;
}
UpdateCheckResult result = await _updates.CheckAsync(cancellationToken).ConfigureAwait(true);
if (result.Error is not null)
{
_log.Warning($"Startup update check failed: {result.Error}");
return;
}
UpdateCheckResult result = await _updates.CheckAsync(cancellationToken).ConfigureAwait(true);
if(result.Error is not null)
{
_log.Warning($"Update check failed: {result.Error}");
await _prompter.ShowMessageAsync(UpdateText.CheckTitle, UpdateText.CheckFailed(result.Error)).ConfigureAwait(true);
return;
}
if (result.AvailableVersion is not null)
{
_log.Info($"Update available: v{result.AvailableVersion}.");
await OfferAsync(result.AvailableVersion, cancellationToken).ConfigureAwait(true);
}
}
if(result.AvailableVersion is null)
{
await _prompter.ShowMessageAsync(UpdateText.CheckTitle, UpdateText.UpToDate(_updates.CurrentVersion)).ConfigureAwait(true);
return;
}
private async Task OfferAsync(string version, CancellationToken cancellationToken)
{
bool accepted = await _prompter.ConfirmAsync(
UpdateText.AvailableTitle, UpdateText.Available(_appName, version), UpdateText.Confirm, UpdateText.Decline).ConfigureAwait(true);
if (!accepted)
{
return;
}
await OfferAsync(result.AvailableVersion, cancellationToken).ConfigureAwait(true);
}
_log.Info($"Installing update v{version}.");
// On success this exits the process to restart into the new version, so
// reaching the lines below means the update did not go through.
string? error = await _updates.DownloadAndApplyAsync(null, cancellationToken).ConfigureAwait(true);
if (error is not null)
{
_log.Error($"Update to v{version} failed: {error}");
await _prompter.ShowMessageAsync(UpdateText.FailedTitle, error).ConfigureAwait(true);
}
}
/// <summary>
/// The startup check: silent unless a newer version exists, in which case the user
/// is offered the update. Unsupported builds and failures are only logged.
/// </summary>
public async Task CheckQuietlyAsync(CancellationToken cancellationToken = default)
{
if(!_updates.IsSupported)
{
return;
}
UpdateCheckResult result = await _updates.CheckAsync(cancellationToken).ConfigureAwait(true);
if(result.Error is not null)
{
_log.Warning($"Startup update check failed: {result.Error}");
return;
}
if(result.AvailableVersion is not null)
{
_log.Info($"Update available: v{result.AvailableVersion}.");
await OfferAsync(result.AvailableVersion, cancellationToken).ConfigureAwait(true);
}
}
/// <summary>Offers the update and, when accepted, downloads it, applies it, and restarts. A failed install is logged and shown, because reaching the end means the restart did not happen.</summary>
private async Task OfferAsync(string version, CancellationToken cancellationToken)
{
bool accepted = await _prompter.ConfirmAsync(
UpdateText.AvailableTitle, UpdateText.Available(_appName, version), UpdateText.Confirm, UpdateText.Decline).ConfigureAwait(true);
if(!accepted)
{
return;
}
_log.Info($"Installing update v{version}.");
// On success this exits the process to restart into the new version, so
// reaching the lines below means the update did not go through.
string? error = await _updates.DownloadAndApplyAsync(null, cancellationToken).ConfigureAwait(true);
if(error is not null)
{
_log.Error($"Update to v{version} failed: {error}");
await _prompter.ShowMessageAsync(UpdateText.FailedTitle, error).ConfigureAwait(true);
}
}
}
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace KofTwentyTwo.AppKit.Updates;
/// <summary>User-facing text of the update flow, shared by every UI package.</summary>
public static class UpdateText
{
/// <summary>Title of the check dialogs.</summary>
public const string CheckTitle = "Check for updates";
/// <summary>Title of the confirmation dialog.</summary>
public const string AvailableTitle = "Update available";
/// <summary>Title shown when installing failed.</summary>
public const string FailedTitle = "Update failed";
/// <summary>Shown in builds that cannot self-update.</summary>
public const string NotSupported = "Updates are only available in installed builds.";
/// <summary>Confirm button.</summary>
public const string Confirm = "Update and restart";
/// <summary>Decline button.</summary>
public const string Decline = "Not now";
/// <summary>"You are up to date (v1.2.3)."</summary>
public static string UpToDate(string? currentVersion)
=> currentVersion is null ? "You are up to date." : $"You are up to date (v{currentVersion}).";
/// <summary>Explains a failed check.</summary>
public static string CheckFailed(string error) => $"Could not check for updates.\n{error}";
/// <summary>Offers the update.</summary>
public static string Available(string appName, string version)
=> $"{appName} v{version} is available. The app will restart to finish installing the update.";
}
@@ -1,7 +1,13 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics.CodeAnalysis;
using Velopack;
using Velopack.Sources;
namespace KofTwentyTwo.AppKit.Updates;
/// <summary>
@@ -13,35 +19,49 @@ namespace KofTwentyTwo.AppKit.Updates;
[ExcludeFromCodeCoverage(Justification = "Pass-through to Velopack; requires an installed app and network access.")]
internal sealed class VelopackGithubBackend : IUpdateBackend
{
private readonly UpdateManager _manager;
private UpdateInfo? _pending;
private readonly UpdateManager _manager;
private UpdateInfo? _pending;
public VelopackGithubBackend(Uri repositoryUrl, bool includePrereleases)
{
_manager = new UpdateManager(new GithubSource(repositoryUrl.ToString(), accessToken: null, prerelease: includePrereleases));
}
public bool IsInstalled => _manager.IsInstalled;
public string? CurrentVersion => _manager.CurrentVersion?.ToString();
/// <summary>Creates a Velopack UpdateManager over the repository&apos;s GitHub Releases, looking at prereleases only when asked.</summary>
public VelopackGithubBackend(Uri repositoryUrl, bool includePrereleases)
{
_manager = new UpdateManager(new GithubSource(repositoryUrl.ToString(), accessToken: null, prerelease: includePrereleases));
}
public async Task<string?> CheckAsync()
{
_pending = await _manager.CheckForUpdatesAsync().ConfigureAwait(false);
return _pending?.TargetFullRelease.Version.ToString();
}
public Task DownloadAsync(Action<int>? progress, CancellationToken cancellationToken)
=> _pending is null
? Task.FromException(new InvalidOperationException("No update was found to download."))
: _manager.DownloadUpdatesAsync(_pending, progress, cancellationToken);
public void ApplyAndRestart()
{
if (_pending is null)
{
throw new InvalidOperationException("No update was found to apply.");
}
_manager.ApplyUpdatesAndRestart(_pending.TargetFullRelease);
}
public bool IsInstalled => _manager.IsInstalled;
public string? CurrentVersion => _manager.CurrentVersion?.ToString();
/// <summary>Asks Velopack for a newer release and remembers it for the download.</summary>
public async Task<string?> CheckAsync()
{
_pending = await _manager.CheckForUpdatesAsync().ConfigureAwait(false);
return _pending?.TargetFullRelease.Version.ToString();
}
/// <summary>Downloads the remembered update with progress; fails when no update was found.</summary>
public Task DownloadAsync(Action<int>? progress, CancellationToken cancellationToken)
=> _pending is null
? Task.FromException(new InvalidOperationException("No update was found to download."))
: _manager.DownloadUpdatesAsync(_pending, progress, cancellationToken);
/// <summary>Applies the downloaded update and restarts the app; Velopack exits the process on success.</summary>
public void ApplyAndRestart()
{
if(_pending is null)
{
throw new InvalidOperationException("No update was found to apply.");
}
_manager.ApplyUpdatesAndRestart(_pending.TargetFullRelease);
}
}
@@ -1,6 +1,12 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics.CodeAnalysis;
using Velopack;
namespace KofTwentyTwo.AppKit.Updates;
/// <summary>
@@ -10,10 +16,10 @@ namespace KofTwentyTwo.AppKit.Updates;
/// </summary>
public static class VelopackStartup
{
/// <summary>
/// Must be the first statement of Main, before any UI exists. WinUI apps therefore
/// define DISABLE_XAML_GENERATED_MAIN and supply their own Program.Main.
/// </summary>
[ExcludeFromCodeCoverage(Justification = "Process-level hook; exercised by the release smoke test of an installed app.")]
public static void Run() => VelopackApp.Build().Run();
/// <summary>
/// Must be the first statement of Main, before any UI exists. WinUI apps therefore
/// define DISABLE_XAML_GENERATED_MAIN and supply their own Program.Main.
/// </summary>
[ExcludeFromCodeCoverage(Justification = "Process-level hook; exercised by the release smoke test of an installed app.")]
public static void Run() => VelopackApp.Build().Run();
}
@@ -1,27 +1,13 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Reflection;
namespace KofTwentyTwo.AppKit.Updates;
/// <summary>
/// The narrow slice of Velopack the update service uses, so the guarding and state
/// logic is testable without an installed app or the network.
/// </summary>
internal interface IUpdateBackend
{
bool IsInstalled { get; }
string? CurrentVersion { get; }
/// <summary>The newer version found (and remembered for download), or null.</summary>
Task<string?> CheckAsync();
/// <summary>Downloads the remembered update.</summary>
Task DownloadAsync(Action<int>? progress, CancellationToken cancellationToken);
/// <summary>Applies the downloaded update and restarts; exits the process on success.</summary>
void ApplyAndRestart();
}
/// <summary>
/// Self-update via Velopack with releases hosted on the app's public GitHub repository.
/// A prerelease build (a '-' in its version) follows the dev channel, whose packages
@@ -31,106 +17,122 @@ internal interface IUpdateBackend
/// </summary>
public sealed class VelopackUpdateService : IUpdateService
{
private readonly Func<IUpdateBackend> _createBackend;
private IUpdateBackend? _backend;
private bool _updateReady;
private readonly Func<IUpdateBackend> _createBackend;
private IUpdateBackend? _backend;
private bool _updateReady;
/// <summary>
/// Updates from <paramref name="repositoryUrl"/>'s GitHub Releases, following the
/// dev channel when the entry assembly is a prerelease build.
/// </summary>
public VelopackUpdateService(Uri repositoryUrl)
: this(repositoryUrl, Assembly.GetEntryAssembly() is { } entry && BuildVersion.IsPrerelease(entry))
{
}
/// <summary>Updates from <paramref name="repositoryUrl"/>'s GitHub Releases.</summary>
/// <param name="repositoryUrl">The public GitHub repository, e.g. https://github.com/KofTwentyTwo/gclo.</param>
/// <param name="includePrereleases">Whether GitHub prereleases (the dev channel) are considered.</param>
public VelopackUpdateService(Uri repositoryUrl, bool includePrereleases)
: this(() => new VelopackGithubBackend(repositoryUrl, includePrereleases))
{
ArgumentNullException.ThrowIfNull(repositoryUrl);
}
/// <summary>Test seam. The backend is created lazily so constructing the service can never fail.</summary>
internal VelopackUpdateService(Func<IUpdateBackend> createBackend)
{
_createBackend = createBackend;
}
/// <summary>
/// Updates from <paramref name="repositoryUrl"/>'s GitHub Releases, following the
/// dev channel when the entry assembly is a prerelease build.
/// </summary>
public VelopackUpdateService(Uri repositoryUrl)
: this(repositoryUrl, Assembly.GetEntryAssembly() is { } entry && BuildVersion.IsPrerelease(entry))
{
}
/// <inheritdoc/>
public bool IsSupported
{
get
{
try
{
return Backend.IsInstalled;
}
catch
{
return false;
}
}
}
/// <inheritdoc/>
public string? CurrentVersion
{
get
{
try
{
return Backend.CurrentVersion;
}
catch
{
return null;
}
}
}
private IUpdateBackend Backend => _backend ??= _createBackend();
/// <summary>Updates from <paramref name="repositoryUrl"/>'s GitHub Releases.</summary>
/// <param name="repositoryUrl">The public GitHub repository, e.g. https://github.com/KofTwentyTwo/gclo.</param>
/// <param name="includePrereleases">Whether GitHub prereleases (the dev channel) are considered.</param>
public VelopackUpdateService(Uri repositoryUrl, bool includePrereleases)
: this(() => new VelopackGithubBackend(repositoryUrl, includePrereleases))
{
ArgumentNullException.ThrowIfNull(repositoryUrl);
}
/// <inheritdoc/>
public async Task<UpdateCheckResult> CheckAsync(CancellationToken cancellationToken = default)
{
_updateReady = false;
try
{
if (!Backend.IsInstalled)
{
return UpdateCheckResult.Failed(UpdateText.NotSupported);
}
string? version = await Backend.CheckAsync().WaitAsync(cancellationToken).ConfigureAwait(false);
_updateReady = version is not null;
return version is null ? UpdateCheckResult.UpToDate : UpdateCheckResult.Available(version);
}
catch (Exception ex)
{
return UpdateCheckResult.Failed(ex.Message);
}
}
/// <inheritdoc/>
public async Task<string?> DownloadAndApplyAsync(IProgress<int>? progress = null, CancellationToken cancellationToken = default)
{
if (!_updateReady || _backend is null)
{
return "No update is ready to install; check for updates first.";
}
/// <summary>Test seam. The backend is created lazily so constructing the service can never fail.</summary>
internal VelopackUpdateService(Func<IUpdateBackend> createBackend)
{
_createBackend = createBackend;
}
try
{
await _backend.DownloadAsync(progress is null ? null : progress.Report, cancellationToken).ConfigureAwait(false);
_backend.ApplyAndRestart();
/// <inheritdoc/>
public bool IsSupported
{
get
{
try
{
return Backend.IsInstalled;
}
catch
{
return false;
}
}
}
/// <inheritdoc/>
public string? CurrentVersion
{
get
{
try
{
return Backend.CurrentVersion;
}
catch
{
return null;
}
catch (Exception ex)
{
return ex.Message;
}
}
}
}
}
private IUpdateBackend Backend => _backend ??= _createBackend();
/// <inheritdoc/>
public async Task<UpdateCheckResult> CheckAsync(CancellationToken cancellationToken = default)
{
_updateReady = false;
try
{
if(!Backend.IsInstalled)
{
return UpdateCheckResult.Failed(UpdateText.NotSupported);
}
string? version = await Backend.CheckAsync().WaitAsync(cancellationToken).ConfigureAwait(false);
_updateReady = version is not null;
return version is null ? UpdateCheckResult.UpToDate : UpdateCheckResult.Available(version);
}
catch(Exception ex)
{
return UpdateCheckResult.Failed(ex.Message);
}
}
/// <inheritdoc/>
public async Task<string?> DownloadAndApplyAsync(IProgress<int>? progress = null, CancellationToken cancellationToken = default)
{
if(!_updateReady || _backend is null)
{
return "No update is ready to install; check for updates first.";
}
try
{
await _backend.DownloadAsync(progress is null ? null : progress.Report, cancellationToken).ConfigureAwait(false);
_backend.ApplyAndRestart();
return null;
}
catch(Exception ex)
{
return ex.Message;
}
}
}
@@ -0,0 +1,22 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.296, )",
"resolved": "3.0.296",
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
},
"Velopack": {
"type": "Direct",
"requested": "[1.2.161, )",
"resolved": "1.2.161",
"contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
},
"koftwentytwo.appkit": {
"type": "Project"
}
}
}
}
+90 -74
View File
@@ -1,9 +1,15 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Reflection;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
namespace KofTwentyTwo.AppKit.WinUI;
/// <summary>
@@ -14,89 +20,99 @@ namespace KofTwentyTwo.AppKit.WinUI;
/// </summary>
public sealed partial class AboutDialog : ContentDialog
{
/// <summary>Builds the dialog for <paramref name="app"/>.</summary>
/// <param name="app">Whose About this is.</param>
/// <param name="versionAssembly">Assembly whose build identity is shown; the entry assembly when null.</param>
public AboutDialog(AppInfo app, Assembly? versionAssembly = null)
{
ArgumentNullException.ThrowIfNull(app);
Title = "About " + app.DisplayName;
CloseButtonText = "Close";
DefaultButton = ContentDialogButton.Close;
/// <summary>Builds the dialog for <paramref name="app"/>.</summary>
/// <param name="app">Whose About this is.</param>
/// <param name="versionAssembly">Assembly whose build identity is shown; the entry assembly when null.</param>
public AboutDialog(AppInfo app, Assembly? versionAssembly = null)
{
ArgumentNullException.ThrowIfNull(app);
Title = "About " + app.DisplayName;
CloseButtonText = "Close";
DefaultButton = ContentDialogButton.Close;
var body = new StackPanel { Spacing = 12, Padding = new Thickness(0, 0, 12, 0) };
body.Children.Add(Header(app, versionAssembly ?? Assembly.GetEntryAssembly()));
var body = new StackPanel { Spacing = 12, Padding = new Thickness(0, 0, 12, 0) };
body.Children.Add(Header(app, versionAssembly ?? Assembly.GetEntryAssembly()));
if (app.Description.Length > 0)
{
body.Children.Add(new TextBlock { Text = app.Description, TextWrapping = TextWrapping.Wrap });
}
if (app.RepositoryUrl is not null)
{
body.Children.Add(new HyperlinkButton { Padding = new Thickness(0), Content = app.RepositoryUrl.ToString(), NavigateUri = app.RepositoryUrl });
}
if(app.Description.Length > 0)
{
body.Children.Add(new TextBlock { Text = app.Description, TextWrapping = TextWrapping.Wrap });
}
if(app.RepositoryUrl is not null)
{
body.Children.Add(new HyperlinkButton { Padding = new Thickness(0), Content = app.RepositoryUrl.ToString(), NavigateUri = app.RepositoryUrl });
}
var license = Section("License");
license.Children.Add(new TextBlock { Text = $"{app.DisplayName} is open source, released under the {app.License}.", TextWrapping = TextWrapping.Wrap });
if (app.Copyright.Length > 0)
{
license.Children.Add(new TextBlock { Text = app.Copyright, TextWrapping = TextWrapping.Wrap });
}
body.Children.Add(license);
StackPanel license = Section("License");
license.Children.Add(new TextBlock { Text = $"{app.DisplayName} is open source, released under the {app.License}.", TextWrapping = TextWrapping.Wrap });
if(app.Copyright.Length > 0)
{
license.Children.Add(new TextBlock { Text = app.Copyright, TextWrapping = TextWrapping.Wrap });
}
body.Children.Add(license);
if (app.Attributions.Count > 0)
{
var components = Section("Open source components");
foreach (Attribution attribution in app.Attributions)
{
var line = new TextBlock { TextWrapping = TextWrapping.Wrap, Style = ThemeStyle("CaptionTextBlockStyle") };
line.Inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = attribution.Name, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });
line.Inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = " — " + attribution.License });
components.Children.Add(line);
}
body.Children.Add(components);
}
if(app.Attributions.Count > 0)
{
StackPanel components = Section("Open source components");
foreach(Attribution attribution in app.Attributions)
{
var line = new TextBlock { TextWrapping = TextWrapping.Wrap, Style = ThemeStyle("CaptionTextBlockStyle") };
line.Inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = attribution.Name, FontWeight = Microsoft.UI.Text.FontWeights.SemiBold });
line.Inlines.Add(new Microsoft.UI.Xaml.Documents.Run { Text = " — " + attribution.License });
components.Children.Add(line);
}
body.Children.Add(components);
}
Content = new ScrollViewer { MaxHeight = 460, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Content = body };
}
Content = new ScrollViewer { MaxHeight = 460, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Content = body };
}
/// <summary>Shows the About dialog for <paramref name="app"/> (ignored if another dialog is open).</summary>
public static Task<ContentDialogResult?> ShowAsync(AppInfo app, XamlRoot xamlRoot, Assembly? versionAssembly = null)
=> DialogGuard.ShowAsync(new AboutDialog(app, versionAssembly) { XamlRoot = xamlRoot });
/// <summary>The splash's look in miniature; deliberately single-theme like the splash.</summary>
private static Border Header(AppInfo app, Assembly? assembly)
{
var title = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 12 };
title.Children.Add(new Image { Width = 40, Height = 40, VerticalAlignment = VerticalAlignment.Center, Source = Brand.IconSource(app.Brand) });
TextBlock name = Brand.Text(app.DisplayName, 28, strong: true);
name.FontFamily = new FontFamily("Segoe UI Variable Display");
name.VerticalAlignment = VerticalAlignment.Center;
title.Children.Add(name);
var stack = new StackPanel { Padding = new Thickness(20, 16, 20, 16), Spacing = 4 };
stack.Children.Add(title);
if (app.Tagline.Length > 0)
{
stack.Children.Add(Brand.Text(app.Tagline, 13, 0.8));
}
if (assembly is not null)
{
TextBlock version = Brand.Text("Version " + BuildVersion.Describe(assembly), 12, 0.7);
AutomationProperties.SetAutomationId(version, "AboutVersionText");
stack.Children.Add(version);
}
/// <summary>Shows the About dialog for <paramref name="app"/> (ignored if another dialog is open).</summary>
public static Task<ContentDialogResult?> ShowAsync(AppInfo app, XamlRoot xamlRoot, Assembly? versionAssembly = null)
=> DialogGuard.ShowAsync(new AboutDialog(app, versionAssembly) { XamlRoot = xamlRoot });
return new Border { CornerRadius = new CornerRadius(8), RequestedTheme = ElementTheme.Dark, Background = Brand.GradientBrush(app.Brand), Child = stack };
}
private static StackPanel Section(string heading)
{
var section = new StackPanel { Spacing = 4 };
section.Children.Add(new TextBlock { Text = heading, Style = ThemeStyle("BodyStrongTextBlockStyle") });
return section;
}
private static Style? ThemeStyle(string key)
=> Application.Current.Resources.TryGetValue(key, out object? style) ? style as Style : null;
/// <summary>The splash's look in miniature; deliberately single-theme like the splash.</summary>
private static Border Header(AppInfo app, Assembly? assembly)
{
var title = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 12 };
title.Children.Add(new Image { Width = 40, Height = 40, VerticalAlignment = VerticalAlignment.Center, Source = Brand.IconSource(app.Brand) });
TextBlock name = Brand.Text(app.DisplayName, 28, strong: true);
name.FontFamily = new FontFamily("Segoe UI Variable Display");
name.VerticalAlignment = VerticalAlignment.Center;
title.Children.Add(name);
var stack = new StackPanel { Padding = new Thickness(20, 16, 20, 16), Spacing = 4 };
stack.Children.Add(title);
if(app.Tagline.Length > 0)
{
stack.Children.Add(Brand.Text(app.Tagline, 13, 0.8));
}
if(assembly is not null)
{
TextBlock version = Brand.Text("Version " + BuildVersion.Describe(assembly), 12, 0.7);
AutomationProperties.SetAutomationId(version, "AboutVersionText");
stack.Children.Add(version);
}
return new Border { CornerRadius = new CornerRadius(8), RequestedTheme = ElementTheme.Dark, Background = Brand.GradientBrush(app.Brand), Child = stack };
}
/// <summary>A titled section of the dialog body: a bold heading the caller adds content lines under.</summary>
private static StackPanel Section(string heading)
{
var section = new StackPanel { Spacing = 4 };
section.Children.Add(new TextBlock { Text = heading, Style = ThemeStyle("BodyStrongTextBlockStyle") });
return section;
}
/// <summary>Looks up a theme style by key; null when the app&apos;s resources lack it, so the text falls back to default styling instead of failing.</summary>
private static Style? ThemeStyle(string key)
=> Application.Current.Resources.TryGetValue(key, out object? style) ? style as Style : null;
}
+48 -34
View File
@@ -1,50 +1,64 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using Microsoft.UI;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using Windows.Foundation;
namespace KofTwentyTwo.AppKit.WinUI;
/// <summary>Turns an <see cref="AppBrand"/> into WinUI brushes and images.</summary>
public static class Brand
{
/// <summary>The WinUI color for <paramref name="color"/>.</summary>
public static Windows.UI.Color ToColor(this ArgbColor color)
=> ColorHelper.FromArgb(color.A, color.R, color.G, color.B);
/// <summary>The WinUI color for <paramref name="color"/>.</summary>
public static Windows.UI.Color ToColor(this ArgbColor color)
=> ColorHelper.FromArgb(color.A, color.R, color.G, color.B);
/// <summary>The brand's diagonal gradient.</summary>
public static LinearGradientBrush GradientBrush(AppBrand brand)
{
ArgumentNullException.ThrowIfNull(brand);
var brush = new LinearGradientBrush { StartPoint = new Point(0, 0), EndPoint = new Point(1, 1) };
brush.GradientStops.Add(new GradientStop { Offset = 0, Color = ArgbColor.Parse(brand.GradientStart).ToColor() });
brush.GradientStops.Add(new GradientStop { Offset = 1, Color = ArgbColor.Parse(brand.GradientEnd).ToColor() });
return brush;
}
/// <summary>
/// The vector mark. ms-appx:/// resolves to the app directory both packaged and
/// unpackaged, so the SVG must be copied to the output (CopyToOutputDirectory).
/// </summary>
public static SvgImageSource IconSource(AppBrand brand)
{
ArgumentNullException.ThrowIfNull(brand);
return new SvgImageSource(new Uri("ms-appx:///" + brand.IconSvgPath.TrimStart('/')));
}
/// <summary>White text, the brand surfaces' only foreground.</summary>
internal static SolidColorBrush White { get; } = new(Colors.White);
/// <summary>The brand's diagonal gradient.</summary>
public static LinearGradientBrush GradientBrush(AppBrand brand)
{
ArgumentNullException.ThrowIfNull(brand);
var brush = new LinearGradientBrush { StartPoint = new Point(0, 0), EndPoint = new Point(1, 1) };
brush.GradientStops.Add(new GradientStop { Offset = 0, Color = ArgbColor.Parse(brand.GradientStart).ToColor() });
brush.GradientStops.Add(new GradientStop { Offset = 1, Color = ArgbColor.Parse(brand.GradientEnd).ToColor() });
return brush;
}
/// <summary>Text at the given opacity over the brand gradient.</summary>
internal static Microsoft.UI.Xaml.Controls.TextBlock Text(string text, double size, double opacity = 1, bool strong = false)
=> new()
{
Text = text,
FontSize = size,
Foreground = White,
Opacity = opacity,
FontWeight = strong ? Microsoft.UI.Text.FontWeights.SemiBold : Microsoft.UI.Text.FontWeights.Normal,
TextWrapping = TextWrapping.Wrap,
};
/// <summary>
/// The vector mark. ms-appx:/// resolves to the app directory both packaged and
/// unpackaged, so the SVG must be copied to the output (CopyToOutputDirectory).
/// </summary>
public static SvgImageSource IconSource(AppBrand brand)
{
ArgumentNullException.ThrowIfNull(brand);
return new SvgImageSource(new Uri("ms-appx:///" + brand.IconSvgPath.TrimStart('/')));
}
/// <summary>White text, the brand surfaces' only foreground.</summary>
internal static SolidColorBrush White { get; } = new(Colors.White);
/// <summary>Text at the given opacity over the brand gradient.</summary>
internal static Microsoft.UI.Xaml.Controls.TextBlock Text(string text, double size, double opacity = 1, bool strong = false)
=> new()
{
Text = text,
FontSize = size,
Foreground = White,
Opacity = opacity,
FontWeight = strong ? Microsoft.UI.Text.FontWeights.SemiBold : Microsoft.UI.Text.FontWeights.Normal,
TextWrapping = TextWrapping.Wrap,
};
}
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Interaction;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace KofTwentyTwo.AppKit.WinUI;
/// <summary>
/// <see cref="IUserPrompter"/> over guarded ContentDialogs, for shared flows such as
/// <see cref="Updates.UpdateCoordinator"/>.
/// </summary>
/// <param name="xamlRoot">Supplies the XamlRoot to host dialogs, usually <c>() =&gt; window.Content.XamlRoot</c>.</param>
public sealed class ContentDialogPrompter(Func<XamlRoot> xamlRoot) : IUserPrompter
{
/// <inheritdoc/>
public async Task ShowMessageAsync(string title, string message)
{
await DialogGuard.ShowAsync(new ContentDialog
{
Title = title,
Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
CloseButtonText = "OK",
DefaultButton = ContentDialogButton.Close,
XamlRoot = xamlRoot(),
});
}
/// <inheritdoc/>
public async Task<bool> ConfirmAsync(string title, string message, string confirmText, string cancelText)
{
ContentDialogResult? result = await DialogGuard.ShowAsync(new ContentDialog
{
Title = title,
Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
PrimaryButtonText = confirmText,
CloseButtonText = cancelText,
DefaultButton = ContentDialogButton.Primary,
XamlRoot = xamlRoot(),
});
return result == ContentDialogResult.Primary;
}
}
+31 -61
View File
@@ -1,7 +1,11 @@
using KofTwentyTwo.AppKit.Interaction;
using Microsoft.UI.Xaml;
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using Microsoft.UI.Xaml.Controls;
namespace KofTwentyTwo.AppKit.WinUI;
/// <summary>
@@ -15,68 +19,34 @@ namespace KofTwentyTwo.AppKit.WinUI;
/// </summary>
public static class DialogGuard
{
private static bool s_dialogOpen;
private static bool s_dialogOpen;
/// <summary>True while a guarded dialog is on screen.</summary>
public static bool IsDialogOpen => s_dialogOpen;
/// <summary>True while a guarded dialog is on screen.</summary>
public static bool IsDialogOpen => s_dialogOpen;
/// <summary>
/// Shows <paramref name="dialog"/> unless another dialog is on screen. Returns the
/// dialog result, or null when the request was ignored; callers treat null like a
/// dismissal.
/// </summary>
public static async Task<ContentDialogResult?> ShowAsync(ContentDialog dialog)
{
ArgumentNullException.ThrowIfNull(dialog);
if (s_dialogOpen)
{
return null;
}
s_dialogOpen = true;
try
{
return await dialog.ShowAsync();
}
finally
{
s_dialogOpen = false;
}
}
}
/// <summary>
/// <see cref="IUserPrompter"/> over guarded ContentDialogs, for shared flows such as
/// <see cref="Updates.UpdateCoordinator"/>.
/// </summary>
/// <param name="xamlRoot">Supplies the XamlRoot to host dialogs, usually <c>() =&gt; window.Content.XamlRoot</c>.</param>
public sealed class ContentDialogPrompter(Func<XamlRoot> xamlRoot) : IUserPrompter
{
/// <inheritdoc/>
public async Task ShowMessageAsync(string title, string message)
{
await DialogGuard.ShowAsync(new ContentDialog
{
Title = title,
Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
CloseButtonText = "OK",
DefaultButton = ContentDialogButton.Close,
XamlRoot = xamlRoot(),
});
}
/// <summary>
/// Shows <paramref name="dialog"/> unless another dialog is on screen. Returns the
/// dialog result, or null when the request was ignored; callers treat null like a
/// dismissal.
/// </summary>
public static async Task<ContentDialogResult?> ShowAsync(ContentDialog dialog)
{
ArgumentNullException.ThrowIfNull(dialog);
if(s_dialogOpen)
{
return null;
}
/// <inheritdoc/>
public async Task<bool> ConfirmAsync(string title, string message, string confirmText, string cancelText)
{
ContentDialogResult? result = await DialogGuard.ShowAsync(new ContentDialog
{
Title = title,
Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
PrimaryButtonText = confirmText,
CloseButtonText = cancelText,
DefaultButton = ContentDialogButton.Primary,
XamlRoot = xamlRoot(),
});
return result == ContentDialogResult.Primary;
}
s_dialogOpen = true;
try
{
return await dialog.ShowAsync();
}
finally
{
s_dialogOpen = false;
}
}
}
@@ -1,34 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Views are built in code, not XAML: a XAML library's compiled resources (.xbf/.pri)
must be merged into the host app, which is fragile for unpackaged (Velopack) apps.
Code-built UI ships as a plain assembly and just works, packaged or not. -->
<PropertyGroup>
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<UseWinUI>true</UseWinUI>
<!-- LibraryImport (GetDpiForWindow) generates unsafe marshalling stubs. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsPackable>true</IsPackable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Description>WinUI 3 shell pieces for KofTwentyTwo Windows apps: branded splash overlay, About dialog, live log viewer, crash safety net, single-dialog guard, theme and window helpers, and the update-dialog adapter. Built in code, so it works in packaged and unpackaged (Velopack) apps alike.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageTags>$(PackageTags);winui3;windowsappsdk</PackageTags>
</PropertyGroup>
<!-- Views are built in code, not XAML: a XAML library's compiled resources (.xbf/.pri)
must be merged into the host app, which is fragile for unpackaged (Velopack) apps.
Code-built UI ships as a plain assembly and just works, packaged or not. -->
<PropertyGroup>
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<UseWinUI>true</UseWinUI>
<!-- LibraryImport (GetDpiForWindow) generates unsafe marshalling stubs. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsPackable>true</IsPackable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Description>WinUI 3 shell pieces for KofTwentyTwo Windows apps: branded splash overlay, About dialog, live log viewer, crash safety net, single-dialog guard, theme and window helpers, and the update-dialog adapter. Built in code, so it works in packaged and unpackaged (Velopack) apps alike.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageTags>$(PackageTags);winui3;windowsappsdk</PackageTags>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" />
<!-- Build-time only: it must not flow to consumers as a package dependency. -->
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" />
<!-- Build-time only: it must not flow to consumers as a package dependency. -->
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\KofTwentyTwo.AppKit\KofTwentyTwo.AppKit.csproj" />
<ProjectReference Include="..\KofTwentyTwo.AppKit.Updates\KofTwentyTwo.AppKit.Updates.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\KofTwentyTwo.AppKit\KofTwentyTwo.AppKit.csproj" />
<ProjectReference Include="..\KofTwentyTwo.AppKit.Updates\KofTwentyTwo.AppKit.Updates.csproj" />
</ItemGroup>
</Project>
+122 -102
View File
@@ -1,9 +1,15 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Logging;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Media;
namespace KofTwentyTwo.AppKit.WinUI;
/// <summary>
@@ -14,122 +20,136 @@ namespace KofTwentyTwo.AppKit.WinUI;
/// </summary>
public sealed partial class LogWindow : Window
{
private static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(1);
private static LogWindow? s_current;
private static readonly TimeSpan s_refreshInterval = TimeSpan.FromSeconds(1);
private static LogWindow? s_current;
private readonly IActivityLog _log;
private readonly DispatcherTimer _refresh = new() { Interval = RefreshInterval };
private readonly TextBlock _pathText = new() { VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, Opacity = 0.7 };
private readonly TextBlock _logText = new() { FontFamily = new FontFamily("Cascadia Mono, Consolas"), FontSize = 12, IsTextSelectionEnabled = true, TextWrapping = TextWrapping.NoWrap };
private readonly ScrollViewer _scroll = new() { HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Padding = new Thickness(12) };
private readonly ToggleButton _follow = new() { Content = "Follow", IsChecked = true };
private readonly ToggleButton _errorsOnly = new() { Content = "Errors only" };
private readonly IActivityLog _log;
private readonly DispatcherTimer _refresh = new() { Interval = s_refreshInterval };
private readonly TextBlock _pathText = new() { VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, Opacity = 0.7 };
private readonly TextBlock _logText = new() { FontFamily = new FontFamily("Cascadia Mono, Consolas"), FontSize = 12, IsTextSelectionEnabled = true, TextWrapping = TextWrapping.NoWrap };
private readonly ScrollViewer _scroll = new() { HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Padding = new Thickness(12) };
private readonly ToggleButton _follow = new() { Content = "Follow", IsChecked = true };
private readonly ToggleButton _errorsOnly = new() { Content = "Errors only" };
private long _lastLength = -1;
private long _lastLength = -1;
/// <summary>Creates a log window over <paramref name="log"/>.</summary>
public LogWindow(IActivityLog log, AppInfo app)
{
ArgumentNullException.ThrowIfNull(log);
ArgumentNullException.ThrowIfNull(app);
_log = log;
Title = app.DisplayName + " — Activity log";
var copy = new Button { Content = "Copy" };
var openFolder = new Button { Content = "Open logs folder" };
_errorsOnly.Click += (_, _) => Reload();
copy.Click += (_, _) => CopyToClipboard();
openFolder.Click += async (_, _) =>
{
if (Directory.Exists(_log.LogDirectory))
{
await Windows.System.Launcher.LaunchFolderPathAsync(_log.LogDirectory);
}
};
var toolbar = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Padding = new Thickness(12, 12, 12, 4) };
toolbar.Children.Add(_follow);
toolbar.Children.Add(_errorsOnly);
toolbar.Children.Add(copy);
toolbar.Children.Add(openFolder);
toolbar.Children.Add(_pathText);
_scroll.Content = _logText;
/// <summary>Creates a log window over <paramref name="log"/>.</summary>
public LogWindow(IActivityLog log, AppInfo app)
{
ArgumentNullException.ThrowIfNull(log);
ArgumentNullException.ThrowIfNull(app);
_log = log;
Title = app.DisplayName + " — Activity log";
var root = new Grid();
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
root.Children.Add(toolbar);
Grid.SetRow(_scroll, 1);
root.Children.Add(_scroll);
Content = root;
var copy = new Button { Content = "Copy" };
var openFolder = new Button { Content = "Open logs folder" };
_errorsOnly.Click += (_, _) => Reload();
copy.Click += (_, _) => CopyToClipboard();
openFolder.Click += async (_, _) =>
{
if(Directory.Exists(_log.LogDirectory))
{
await Windows.System.Launcher.LaunchFolderPathAsync(_log.LogDirectory);
}
};
this.ResizeForDpi(1000, 680);
this.SetAppIcon(app);
var toolbar = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Padding = new Thickness(12, 12, 12, 4) };
toolbar.Children.Add(_follow);
toolbar.Children.Add(_errorsOnly);
toolbar.Children.Add(copy);
toolbar.Children.Add(openFolder);
toolbar.Children.Add(_pathText);
_scroll.Content = _logText;
_refresh.Tick += (_, _) => RefreshIfChanged();
_refresh.Start();
Closed += (_, _) => _refresh.Stop();
var root = new Grid();
root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
root.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
root.Children.Add(toolbar);
Grid.SetRow(_scroll, 1);
root.Children.Add(_scroll);
Content = root;
Reload();
}
this.ResizeForDpi(1000, 680);
this.SetAppIcon(app);
/// <summary>
/// Activates the open log window, or opens one. Close it from the main window's
/// Closed handler (<see cref="CloseCurrent"/>) or a log-only process lingers.
/// </summary>
public static LogWindow ShowSingle(IActivityLog log, AppInfo app)
{
if (s_current is null)
{
s_current = new LogWindow(log, app);
s_current.Closed += (_, _) => s_current = null;
}
s_current.Activate();
return s_current;
}
_refresh.Tick += (_, _) => RefreshIfChanged();
_refresh.Start();
Closed += (_, _) => _refresh.Stop();
/// <summary>Closes the window <see cref="ShowSingle"/> opened, if any.</summary>
public static void CloseCurrent() => s_current?.Close();
Reload();
}
private void CopyToClipboard()
{
var package = new Windows.ApplicationModel.DataTransfer.DataPackage();
package.SetText(_logText.Text);
Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(package);
}
/// <summary>Timer tick: reload only when Follow is on and the file grew or rolled over.</summary>
private void RefreshIfChanged()
{
if (_follow.IsChecked != true)
{
return;
}
try
{
string path = _log.CurrentLogFilePath;
long length = File.Exists(path) ? new FileInfo(path).Length : 0;
if (length != _lastLength)
{
Reload();
}
}
catch
{
// Diagnostics UI must never take the app down over an unreadable file.
}
}
/// <summary>
/// Activates the open log window, or opens one. Close it from the main window's
/// Closed handler (<see cref="CloseCurrent"/>) or a log-only process lingers.
/// </summary>
public static LogWindow ShowSingle(IActivityLog log, AppInfo app)
{
if(s_current is null)
{
s_current = new LogWindow(log, app);
s_current.Closed += (_, _) => s_current = null;
}
s_current.Activate();
return s_current;
}
private void Reload()
{
_pathText.Text = _log.CurrentLogFilePath; // the day can roll over while the window is open
LogSnapshot snapshot = LogTail.Read(_log.CurrentLogFilePath);
_lastLength = snapshot.Length;
bool errorsOnly = _errorsOnly.IsChecked == true;
_logText.Text = LogTail.ToDisplayText(errorsOnly ? LogTail.ErrorsOnly(snapshot.Lines) : snapshot.Lines, errorsOnly);
_scroll.UpdateLayout();
_scroll.ChangeView(null, _scroll.ScrollableHeight, null, disableAnimation: true);
}
/// <summary>Closes the window <see cref="ShowSingle"/> opened, if any.</summary>
public static void CloseCurrent() => s_current?.Close();
/// <summary>Copies the visible log text to the clipboard.</summary>
private void CopyToClipboard()
{
var package = new Windows.ApplicationModel.DataTransfer.DataPackage();
package.SetText(_logText.Text);
Windows.ApplicationModel.DataTransfer.Clipboard.SetContent(package);
}
/// <summary>Timer tick: reload only when Follow is on and the file grew or rolled over.</summary>
private void RefreshIfChanged()
{
if(_follow.IsChecked != true)
{
return;
}
try
{
string path = _log.CurrentLogFilePath;
long length = File.Exists(path) ? new FileInfo(path).Length : 0;
if(length != _lastLength)
{
Reload();
}
}
catch
{
// Diagnostics UI must never take the app down over an unreadable file.
}
}
/// <summary>Rereads the log tail, applies the errors-only filter, and scrolls to the newest entry.</summary>
private void Reload()
{
_pathText.Text = _log.CurrentLogFilePath; // the day can roll over while the window is open
LogSnapshot snapshot = LogTail.Read(_log.CurrentLogFilePath);
_lastLength = snapshot.Length;
bool errorsOnly = _errorsOnly.IsChecked == true;
_logText.Text = LogTail.ToDisplayText(errorsOnly ? LogTail.ErrorsOnly(snapshot.Lines) : snapshot.Lines, errorsOnly);
_scroll.UpdateLayout();
_scroll.ChangeView(null, _scroll.ScrollableHeight, null, disableAnimation: true);
}
}
+186 -127
View File
@@ -1,3 +1,8 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Reflection;
using KofTwentyTwo.AppKit.Settings;
using Microsoft.UI.Xaml;
@@ -6,6 +11,7 @@ using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Animation;
namespace KofTwentyTwo.AppKit.WinUI;
/// <summary>
@@ -18,145 +24,198 @@ namespace KofTwentyTwo.AppKit.WinUI;
/// </summary>
public sealed partial class SplashOverlay : UserControl
{
private readonly Storyboard _fadeOut = new();
private bool _dismissed;
private readonly Storyboard _fadeOut = new();
private bool _dismissed;
/// <summary>Builds the overlay for <paramref name="app"/>.</summary>
/// <param name="app">Whose splash this is.</param>
/// <param name="versionAssembly">Assembly whose build identity is shown; the entry assembly when null.</param>
public SplashOverlay(AppInfo app, Assembly? versionAssembly = null)
{
ArgumentNullException.ThrowIfNull(app);
var root = new Grid { RequestedTheme = ElementTheme.Dark, Background = Brand.GradientBrush(app.Brand) };
AutomationProperties.SetName(root, $"{app.DisplayName} is starting");
AutomationProperties.SetAutomationId(root, "AppKitSplash");
var center = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
center.Children.Add(new Image
{
Width = 96,
Height = 96,
Margin = new Thickness(0, 0, 0, 16),
HorizontalAlignment = HorizontalAlignment.Center,
Source = Brand.IconSource(app.Brand),
});
TextBlock title = Brand.Text(app.DisplayName, 40, strong: true);
title.FontFamily = new FontFamily("Segoe UI Variable Display");
AddCentered(center, title);
if (app.Tagline.Length > 0)
{
TextBlock tagline = Brand.Text(app.Tagline, 14, 0.8);
tagline.Margin = new Thickness(0, 4, 0, 0);
AddCentered(center, tagline);
}
Assembly? assembly = versionAssembly ?? Assembly.GetEntryAssembly();
if (assembly is not null)
{
TextBlock version = Brand.Text(BuildVersion.Describe(assembly), 12, 0.6);
version.Margin = new Thickness(0, 8, 0, 0);
AddCentered(center, version);
}
root.Children.Add(center);
/// <summary>Builds the overlay for <paramref name="app"/>.</summary>
/// <param name="app">Whose splash this is.</param>
/// <param name="versionAssembly">Assembly whose build identity is shown; the entry assembly when null.</param>
public SplashOverlay(AppInfo app, Assembly? versionAssembly = null)
{
ArgumentNullException.ThrowIfNull(app);
string contact = Credits.ContactLine(app);
if (app.Author.Length > 0 || contact.Length > 0)
{
var credit = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Bottom,
Margin = new Thickness(0, 0, 0, 44),
Spacing = 3,
};
if (app.Author.Length > 0)
{
AddCentered(credit, Brand.Text("Created by " + app.Author, 13, 0.85, strong: true));
}
if (contact.Length > 0)
{
AddCentered(credit, Brand.Text(contact, 12, 0.6));
}
root.Children.Add(credit);
}
var root = new Grid { RequestedTheme = ElementTheme.Dark, Background = Brand.GradientBrush(app.Brand) };
AutomationProperties.SetName(root, $"{app.DisplayName} is starting");
AutomationProperties.SetAutomationId(root, "AppKitSplash");
// Thin activity strip: white bar over a 20% white track.
var progress = new ProgressBar
{
IsIndeterminate = true,
Height = 3,
MinHeight = 3,
Foreground = Brand.White,
Background = new SolidColorBrush(ArgbColor.Parse("#33FFFFFF").ToColor()),
VerticalAlignment = VerticalAlignment.Bottom,
Margin = new Thickness(0, 0, 0, 20),
};
progress.Resources["ProgressBarTrackHeight"] = 3.0;
root.Children.Add(progress);
root.Children.Add(BuildCenter(app, versionAssembly ?? Assembly.GetEntryAssembly()));
if(BuildCredit(app) is { } credit)
{
root.Children.Add(credit);
}
root.Children.Add(BuildActivityStrip());
var fade = new DoubleAnimation { To = 0, Duration = new Duration(TimeSpan.FromMilliseconds(250)) };
Storyboard.SetTarget(fade, root);
Storyboard.SetTargetProperty(fade, "Opacity");
_fadeOut.Children.Add(fade);
var fade = new DoubleAnimation { To = 0, Duration = new Duration(TimeSpan.FromMilliseconds(250)) };
Storyboard.SetTarget(fade, root);
Storyboard.SetTargetProperty(fade, "Opacity");
_fadeOut.Children.Add(fade);
Content = root;
}
Content = root;
}
/// <summary>
/// Shows a splash over <paramref name="host"/> (the window's root Grid) when
/// <paramref name="settings"/> enables it, and dismisses it after the configured
/// time. Returns the overlay, or null when the splash is turned off.
/// </summary>
public static SplashOverlay? ShowOver(Panel host, AppInfo app, ShellSettings settings, Assembly? versionAssembly = null)
{
ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(settings);
if (!settings.ShowSplashScreen)
{
return null;
}
var overlay = new SplashOverlay(app, versionAssembly);
if (host is Grid grid)
{
Grid.SetRowSpan(overlay, Math.Max(grid.RowDefinitions.Count, 1));
Grid.SetColumnSpan(overlay, Math.Max(grid.ColumnDefinitions.Count, 1));
}
host.Children.Add(overlay);
overlay.DispatcherQueue.TryEnqueue(async () =>
{
await Task.Delay(settings.SplashMilliseconds);
await overlay.DismissAsync();
});
return overlay;
}
/// <summary>
/// Fades the overlay out and removes it from its parent panel. Safe to call more
/// than once; only the first call does anything.
/// </summary>
public async Task DismissAsync()
{
if (_dismissed)
{
return;
}
_dismissed = true;
/// <summary>
/// The centered brand block: the vector mark, the name, the tagline, and the build
/// identity of <paramref name="assembly"/> when there is one.
/// </summary>
private static StackPanel BuildCenter(AppInfo app, Assembly? assembly)
{
var center = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
center.Children.Add(new Image
{
Width = 96,
Height = 96,
Margin = new Thickness(0, 0, 0, 16),
HorizontalAlignment = HorizontalAlignment.Center,
Source = Brand.IconSource(app.Brand),
});
TextBlock title = Brand.Text(app.DisplayName, 40, strong: true);
title.FontFamily = new FontFamily("Segoe UI Variable Display");
AddCentered(center, title);
if(app.Tagline.Length > 0)
{
TextBlock tagline = Brand.Text(app.Tagline, 14, 0.8);
tagline.Margin = new Thickness(0, 4, 0, 0);
AddCentered(center, tagline);
}
if(assembly is not null)
{
TextBlock version = Brand.Text(BuildVersion.Describe(assembly), 12, 0.6);
version.Margin = new Thickness(0, 8, 0, 0);
AddCentered(center, version);
}
return center;
}
// 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);
}
/// <summary>
/// The author credit anchored above the activity strip, or null when the app declares
/// neither an author nor any contact details.
/// </summary>
private static StackPanel? BuildCredit(AppInfo app)
{
string contact = Credits.ContactLine(app);
if(app.Author.Length == 0 && contact.Length == 0)
{
return null;
}
private static void AddCentered(Panel panel, FrameworkElement element)
{
element.HorizontalAlignment = HorizontalAlignment.Center;
panel.Children.Add(element);
}
var credit = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Bottom,
Margin = new Thickness(0, 0, 0, 44),
Spacing = 3,
};
if(app.Author.Length > 0)
{
AddCentered(credit, Brand.Text("Created by " + app.Author, 13, 0.85, strong: true));
}
if(contact.Length > 0)
{
AddCentered(credit, Brand.Text(contact, 12, 0.6));
}
return credit;
}
/// <summary>
/// The thin activity strip at the bottom: a white bar over a 20% white track, with the
/// track height resource overridden so the track matches the 3px bar.
/// </summary>
private static ProgressBar BuildActivityStrip()
{
var progress = new ProgressBar
{
IsIndeterminate = true,
Height = 3,
MinHeight = 3,
Foreground = Brand.White,
Background = new SolidColorBrush(ArgbColor.Parse("#33FFFFFF").ToColor()),
VerticalAlignment = VerticalAlignment.Bottom,
Margin = new Thickness(0, 0, 0, 20),
};
progress.Resources["ProgressBarTrackHeight"] = 3.0;
return progress;
}
/// <summary>
/// Shows a splash over <paramref name="host"/> (the window's root Grid) when
/// <paramref name="settings"/> enables it, and dismisses it after the configured
/// time. Returns the overlay, or null when the splash is turned off.
/// </summary>
public static SplashOverlay? ShowOver(Panel host, AppInfo app, ShellSettings settings, Assembly? versionAssembly = null)
{
ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(settings);
if(!settings.ShowSplashScreen)
{
return null;
}
var overlay = new SplashOverlay(app, versionAssembly);
if(host is Grid grid)
{
Grid.SetRowSpan(overlay, Math.Max(grid.RowDefinitions.Count, 1));
Grid.SetColumnSpan(overlay, Math.Max(grid.ColumnDefinitions.Count, 1));
}
host.Children.Add(overlay);
///////////////////////////////////////////////////////////////////////////////
// start the countdown through the dispatcher queue, not directly: the queue //
// runs it on the UI thread with its synchronization context in place, so //
// the fade after the delay also runs on the UI thread //
///////////////////////////////////////////////////////////////////////////////
overlay.DispatcherQueue.TryEnqueue(() => _ = overlay.DismissAfterAsync(settings.SplashMilliseconds));
return overlay;
}
/// <summary>Waits <paramref name="milliseconds"/>, then fades the overlay out.</summary>
private async Task DismissAfterAsync(int milliseconds)
{
await Task.Delay(milliseconds);
await DismissAsync();
}
/// <summary>
/// Fades the overlay out and removes it from its parent panel. Safe to call more
/// than once; only the first call does anything.
/// </summary>
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);
}
/// <summary>Centers an element horizontally and adds it to the panel.</summary>
private static void AddCentered(Panel panel, FrameworkElement element)
{
element.HorizontalAlignment = HorizontalAlignment.Center;
panel.Children.Add(element);
}
}
+59 -103
View File
@@ -1,119 +1,75 @@
using System.Runtime.InteropServices;
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Logging;
using KofTwentyTwo.AppKit.Settings;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
namespace KofTwentyTwo.AppKit.WinUI;
/// <summary>App-level wiring every WinUI app repeats: crash net and theme.</summary>
public static class WinUIShell
{
/// <summary>
/// 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. This records it and keeps the app
/// running. Call from the App constructor.
/// </summary>
/// <param name="application">The app.</param>
/// <param name="log">Supplies the log; may return null early in startup.</param>
public static void InstallCrashNet(Application application, Func<IActivityLog?> log)
{
ArgumentNullException.ThrowIfNull(application);
ArgumentNullException.ThrowIfNull(log);
application.UnhandledException += (_, e) =>
{
e.Handled = true;
try
{
log()?.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.
}
};
}
/// <summary>
/// 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. This records it and keeps the app
/// running. Call from the App constructor.
/// </summary>
/// <param name="application">The app.</param>
/// <param name="log">Supplies the log; may return null early in startup.</param>
public static void InstallCrashNet(Application application, Func<IActivityLog?> log)
{
ArgumentNullException.ThrowIfNull(application);
ArgumentNullException.ThrowIfNull(log);
application.UnhandledException += (_, e) =>
{
e.Handled = true;
try
{
log()?.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.
}
};
}
/// <summary>
/// Applies the startup theme. Only valid in the App constructor, before any window
/// exists; for live changes use <see cref="ApplyTheme(FrameworkElement, AppTheme)"/>.
/// </summary>
public static void ApplyTheme(Application application, AppTheme theme)
{
ArgumentNullException.ThrowIfNull(application);
if (theme == AppTheme.Light)
{
application.RequestedTheme = ApplicationTheme.Light;
}
else if (theme == AppTheme.Dark)
{
application.RequestedTheme = ApplicationTheme.Dark;
}
}
/// <summary>Applies a theme change to a window's root element while the app runs.</summary>
public static void ApplyTheme(FrameworkElement root, AppTheme theme)
{
ArgumentNullException.ThrowIfNull(root);
root.RequestedTheme = theme switch
{
AppTheme.Light => ElementTheme.Light,
AppTheme.Dark => ElementTheme.Dark,
_ => ElementTheme.Default,
};
}
}
/// <summary>DPI-aware sizing and branding for WinUI windows.</summary>
public static partial class WindowExtensions
{
/// <summary>The window's display scale (1.0 at 100%, 1.5 at 150%).</summary>
public static double DpiScale(this Window window)
{
ArgumentNullException.ThrowIfNull(window);
IntPtr hwnd = WinRT.Interop.WindowNative.GetWindowHandle(window);
uint dpi = GetDpiForWindow(hwnd);
return dpi == 0 ? 1.0 : dpi / 96.0;
}
/// <summary>
/// Applies the startup theme. Only valid in the App constructor, before any window
/// exists; for live changes use <see cref="ApplyTheme(FrameworkElement, AppTheme)"/>.
/// </summary>
public static void ApplyTheme(Application application, AppTheme theme)
{
ArgumentNullException.ThrowIfNull(application);
if(theme == AppTheme.Light)
{
application.RequestedTheme = ApplicationTheme.Light;
}
else if(theme == AppTheme.Dark)
{
application.RequestedTheme = ApplicationTheme.Dark;
}
}
/// <summary>
/// Resizes to <paramref name="width"/> x <paramref name="height"/> effective pixels.
/// AppWindow.Resize takes physical pixels, so this scales by the monitor DPI.
/// </summary>
public static void ResizeForDpi(this Window window, int width, int height)
{
double scale = window.DpiScale();
window.AppWindow.Resize(new Windows.Graphics.SizeInt32((int)(width * scale), (int)(height * scale)));
}
/// <summary>Keeps the window from shrinking below a usable size, in effective pixels.</summary>
public static void SetMinimumSize(this Window window, int width, int height)
{
double scale = window.DpiScale();
if (window.AppWindow.Presenter is OverlappedPresenter presenter)
{
presenter.PreferredMinimumWidth = (int)(width * scale);
presenter.PreferredMinimumHeight = (int)(height * scale);
}
}
/// <summary>
/// Sets the brand icon on the title bar (and taskbar / Alt-Tab for unpackaged runs).
/// The .ico must be copied to the output directory. A missing file is ignored.
/// </summary>
public static void SetAppIcon(this Window window, AppInfo app)
{
ArgumentNullException.ThrowIfNull(window);
ArgumentNullException.ThrowIfNull(app);
string path = Path.Combine(AppContext.BaseDirectory, app.Brand.IconIcoPath);
if (File.Exists(path))
{
window.AppWindow.SetIcon(path);
}
}
[LibraryImport("user32.dll")]
private static partial uint GetDpiForWindow(IntPtr hwnd);
/// <summary>Applies a theme change to a window's root element while the app runs.</summary>
public static void ApplyTheme(FrameworkElement root, AppTheme theme)
{
ArgumentNullException.ThrowIfNull(root);
root.RequestedTheme = theme switch
{
AppTheme.Light => ElementTheme.Light,
AppTheme.Dark => ElementTheme.Dark,
_ => ElementTheme.Default,
};
}
}
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Runtime.InteropServices;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
namespace KofTwentyTwo.AppKit.WinUI;
/// <summary>DPI-aware sizing and branding for WinUI windows.</summary>
public static partial class WindowExtensions
{
/// <summary>The window's display scale (1.0 at 100%, 1.5 at 150%).</summary>
public static double DpiScale(this Window window)
{
ArgumentNullException.ThrowIfNull(window);
IntPtr hwnd = WinRT.Interop.WindowNative.GetWindowHandle(window);
uint dpi = GetDpiForWindow(hwnd);
return dpi == 0 ? 1.0 : dpi / 96.0;
}
/// <summary>
/// Resizes to <paramref name="width"/> x <paramref name="height"/> effective pixels.
/// AppWindow.Resize takes physical pixels, so this scales by the monitor DPI.
/// </summary>
public static void ResizeForDpi(this Window window, int width, int height)
{
double scale = window.DpiScale();
window.AppWindow.Resize(new Windows.Graphics.SizeInt32((int)(width * scale), (int)(height * scale)));
}
/// <summary>Keeps the window from shrinking below a usable size, in effective pixels.</summary>
public static void SetMinimumSize(this Window window, int width, int height)
{
double scale = window.DpiScale();
if(window.AppWindow.Presenter is OverlappedPresenter presenter)
{
presenter.PreferredMinimumWidth = (int)(width * scale);
presenter.PreferredMinimumHeight = (int)(height * scale);
}
}
/// <summary>
/// Sets the brand icon on the title bar (and taskbar / Alt-Tab for unpackaged runs).
/// The .ico must be copied to the output directory. A missing file is ignored.
/// </summary>
public static void SetAppIcon(this Window window, AppInfo app)
{
ArgumentNullException.ThrowIfNull(window);
ArgumentNullException.ThrowIfNull(app);
string path = Path.Combine(AppContext.BaseDirectory, app.Brand.IconIcoPath);
if(File.Exists(path))
{
window.AppWindow.SetIcon(path);
}
}
/// <summary>Win32 GetDpiForWindow: the effective DPI of the monitor the window is on.</summary>
[LibraryImport("user32.dll")]
private static partial uint GetDpiForWindow(IntPtr hwnd);
}
@@ -0,0 +1,166 @@
{
"version": 2,
"dependencies": {
"net10.0-windows10.0.19041": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.296, )",
"resolved": "3.0.296",
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
},
"Microsoft.Windows.SDK.BuildTools": {
"type": "Direct",
"requested": "[10.0.28000.2705, )",
"resolved": "10.0.28000.2705",
"contentHash": "TZQ96xtu7+fyFsyJCgj6Hazru0H8G1kiE1CeZLGpsqT4xKQmkxWu9yRuxYLeHJECjqEHN7duZUTcdbt8zlQ8OQ=="
},
"Microsoft.WindowsAppSDK": {
"type": "Direct",
"requested": "[2.5.1, )",
"resolved": "2.5.1",
"contentHash": "WgKPzT1H2s17azODD1jNZje69z1hGz8VSh3bIylTtXHVg7hLyrn8Y+rswFqF41UX8ea7ELeoyqN80zZ6EHwCFA==",
"dependencies": {
"Microsoft.WindowsAppSDK.AI": "2.5.5",
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.DWrite": "2.1.0",
"Microsoft.WindowsAppSDK.Foundation": "2.3.12",
"Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.9",
"Microsoft.WindowsAppSDK.ML": "2.1.94",
"Microsoft.WindowsAppSDK.Runtime": "[2.5.1]",
"Microsoft.WindowsAppSDK.Search": "2.5.5",
"Microsoft.WindowsAppSDK.Widgets": "2.0.5",
"Microsoft.WindowsAppSDK.WinUI": "2.3.9"
}
},
"Microsoft.Web.WebView2": {
"type": "Transitive",
"resolved": "1.0.3719.77",
"contentHash": "t+ucyKw5NTwMjsUrDF6R9Lk40lpcKQD1/HgyGFxl49tdA4h9dKlsj6FYGEmDRpFNfnTpENuTypMcdbrlkqBdDA=="
},
"Microsoft.Windows.AI.MachineLearning": {
"type": "Transitive",
"resolved": "2.1.74",
"contentHash": "/Z1yAQ0J53eD1RrOl5anTDS0UQOgneERY//hLljwGcWeUX9ynCvOl5qUxAsfWvwsZZGcvV4WKjAvnAtA6N5i+w==",
"dependencies": {
"System.Numerics.Tensors": "9.0.0"
}
},
"Microsoft.Windows.SDK.BuildTools.MSIX": {
"type": "Transitive",
"resolved": "1.7.251221100",
"contentHash": "f4aIZJ0NUth2403oxrpR+9rxVzZVI6dabqB21u8ncnk8eJAKCs9m77E4iYAnvP1YwrRe4axz3f8+yUcttNSfEA=="
},
"Microsoft.WindowsAppSDK.AI": {
"type": "Transitive",
"resolved": "2.5.5",
"contentHash": "f6XA05BsTAmDDsWGiFQdcAujNlbET5QbqxhBo7iHPJJix+RJajXKv1XlO19M78GtEpi2AYGEv08dPxER8ESYAw==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.Foundation": "2.3.12"
}
},
"Microsoft.WindowsAppSDK.Base": {
"type": "Transitive",
"resolved": "2.0.4",
"contentHash": "QXSy2llX2/Bx9dYWGUEsb10F40q94ZiDnPMzqa2/qRmTG4y9EXxGp6uHITb7c0b4FCa+6KFBlgh6D53M0QEMEg==",
"dependencies": {
"Microsoft.Windows.SDK.BuildTools": "10.0.26100.4654",
"Microsoft.Windows.SDK.BuildTools.MSIX": "1.7.251221100"
}
},
"Microsoft.WindowsAppSDK.DWrite": {
"type": "Transitive",
"resolved": "2.1.0",
"contentHash": "dgix7NSeo7Z8SZPyrsOqYm/6OUvBveHCiVyorYecmtDYoZW26dJ6/i9yveIxgWvmFpgfKXPeIuQBCvhbetHiYg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.3"
}
},
"Microsoft.WindowsAppSDK.Foundation": {
"type": "Transitive",
"resolved": "2.3.12",
"contentHash": "UflrNbXoZmHPt8lLGDk4gQ+yoGfQlCHd3TVTy7YwCMZ/aVGWAiLDqGCEjtWmNsI8ySy6imx3ZlenKfd2/VMPEg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.8"
}
},
"Microsoft.WindowsAppSDK.InteractiveExperiences": {
"type": "Transitive",
"resolved": "2.1.9",
"contentHash": "59nvb3pQYsBTTGrobeHkB4bkxASHtpaORJl7r6Zvg8PSIDCupEZTPKrIhnUknHDGfAtuBsnORjgvRi2WXGaRhA==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4"
}
},
"Microsoft.WindowsAppSDK.ML": {
"type": "Transitive",
"resolved": "2.1.94",
"contentHash": "b21CWVubc3WqWNJ9+fy7n+s83LgwBpIzYrOuSUmHAqPB0pH+ttjgiknnDA/oF7wCbjiaUb4KDXVV7Mya3f18Ug==",
"dependencies": {
"Microsoft.Windows.AI.MachineLearning": "[2.1.74, 3.0.0)",
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.Foundation": "2.3.12"
}
},
"Microsoft.WindowsAppSDK.Runtime": {
"type": "Transitive",
"resolved": "2.5.1",
"contentHash": "EDzGf6Tq9njNfNsTfqKdiwmSzFF/caHItuDa99UTQiOvLXwOjCL1k75/PMLSoHbCo1cSlHh/sSqZVssJ/Z0NzA==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.4"
}
},
"Microsoft.WindowsAppSDK.Search": {
"type": "Transitive",
"resolved": "2.5.5",
"contentHash": "uvYkXi9TB4rk/kgWn5XHEkMNMU+xhjY+S0QWsxEs8wYT/dXAjfHvW30+JVoW/THZ/9jIMinEn4gliCSVRFV0Sw==",
"dependencies": {
"Microsoft.WindowsAppSDK.AI": "[2.5.5]",
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.Foundation": "2.3.12"
}
},
"Microsoft.WindowsAppSDK.Widgets": {
"type": "Transitive",
"resolved": "2.0.5",
"contentHash": "lRz+8+QU65gV8hRzLVE+GRsPKZ42lnzqkmsw96HzQd/DuFYfr4JYpUU7ZZ5YrkV2EfQe4BCmoRKAeZ3WwalMcg==",
"dependencies": {
"Microsoft.WindowsAppSDK.Base": "2.0.3"
}
},
"Microsoft.WindowsAppSDK.WinUI": {
"type": "Transitive",
"resolved": "2.3.9",
"contentHash": "GiuYUQqMYD5lycT+X2AGX1rlbHU/zf8xoPeZNXZbJj4mZcSr4STsxeRUMxnTUQLAmmjjQaPrTIRjn78vbPhAvg==",
"dependencies": {
"Microsoft.Web.WebView2": "1.0.3719.77",
"Microsoft.WindowsAppSDK.Base": "2.0.4",
"Microsoft.WindowsAppSDK.Foundation": "2.3.12",
"Microsoft.WindowsAppSDK.InteractiveExperiences": "2.1.8"
}
},
"System.Numerics.Tensors": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "hyJB4UlpAi19Xr9AXzu2NuagKC4lPfHObNMEAA0HmqFz2rX7wKgzeYzO/jM/eBHDhnUGFFEjk5cOoJaxqg5J4A=="
},
"koftwentytwo.appkit": {
"type": "Project"
},
"koftwentytwo.appkit.updates": {
"type": "Project",
"dependencies": {
"KofTwentyTwo.AppKit": "[0.1.0-dev, )",
"Velopack": "[1.2.161, )"
}
},
"Velopack": {
"type": "CentralTransitive",
"requested": "[1.2.161, )",
"resolved": "1.2.161",
"contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
}
}
}
}
+99 -82
View File
@@ -1,3 +1,8 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics;
using System.Reflection;
using System.Windows;
@@ -6,6 +11,7 @@ using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Media;
namespace KofTwentyTwo.AppKit.Wpf;
/// <summary>
@@ -15,98 +21,109 @@ namespace KofTwentyTwo.AppKit.Wpf;
/// </summary>
public sealed class AboutWindow : Window
{
/// <summary>Builds the window for <paramref name="app"/>.</summary>
/// <param name="app">Whose About this is.</param>
/// <param name="versionAssembly">Assembly whose build identity is shown; the entry assembly when null.</param>
public AboutWindow(AppInfo app, Assembly? versionAssembly = null)
{
ArgumentNullException.ThrowIfNull(app);
Title = "About " + app.DisplayName;
Width = 480;
SizeToContent = SizeToContent.Height;
MaxHeight = 640;
ResizeMode = ResizeMode.NoResize;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
ShowInTaskbar = false;
this.SetAppIcon(app);
/// <summary>Builds the window for <paramref name="app"/>.</summary>
/// <param name="app">Whose About this is.</param>
/// <param name="versionAssembly">Assembly whose build identity is shown; the entry assembly when null.</param>
public AboutWindow(AppInfo app, Assembly? versionAssembly = null)
{
ArgumentNullException.ThrowIfNull(app);
Title = "About " + app.DisplayName;
Width = 480;
SizeToContent = SizeToContent.Height;
MaxHeight = 640;
ResizeMode = ResizeMode.NoResize;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
ShowInTaskbar = false;
this.SetAppIcon(app);
var body = new StackPanel { Margin = new Thickness(20) };
body.Children.Add(Header(app, versionAssembly ?? Assembly.GetEntryAssembly()));
var body = new StackPanel { Margin = new Thickness(20) };
body.Children.Add(Header(app, versionAssembly ?? Assembly.GetEntryAssembly()));
if (app.Description.Length > 0)
{
body.Children.Add(Paragraph(app.Description));
}
if (app.RepositoryUrl is not null)
{
var link = new Hyperlink(new Run(app.RepositoryUrl.ToString())) { NavigateUri = app.RepositoryUrl };
link.RequestNavigate += (_, e) => Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true });
body.Children.Add(new TextBlock(link) { Margin = new Thickness(0, 12, 0, 0) });
}
if(app.Description.Length > 0)
{
body.Children.Add(Paragraph(app.Description));
}
if(app.RepositoryUrl is not null)
{
var link = new Hyperlink(new Run(app.RepositoryUrl.ToString())) { NavigateUri = app.RepositoryUrl };
link.RequestNavigate += (_, e) => Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true });
body.Children.Add(new TextBlock(link) { Margin = new Thickness(0, 12, 0, 0) });
}
body.Children.Add(Heading("License"));
body.Children.Add(Paragraph($"{app.DisplayName} is open source, released under the {app.License}.", top: 4));
if (app.Copyright.Length > 0)
{
body.Children.Add(Paragraph(app.Copyright, top: 4));
}
body.Children.Add(Heading("License"));
body.Children.Add(Paragraph($"{app.DisplayName} is open source, released under the {app.License}.", top: 4));
if(app.Copyright.Length > 0)
{
body.Children.Add(Paragraph(app.Copyright, top: 4));
}
if (app.Attributions.Count > 0)
{
body.Children.Add(Heading("Open source components"));
foreach (Attribution attribution in app.Attributions)
{
var line = new TextBlock { TextWrapping = TextWrapping.Wrap, FontSize = 12, Margin = new Thickness(0, 4, 0, 0) };
line.Inlines.Add(new Run(attribution.Name) { FontWeight = FontWeights.SemiBold });
line.Inlines.Add(new Run(" — " + attribution.License));
body.Children.Add(line);
}
}
if(app.Attributions.Count > 0)
{
body.Children.Add(Heading("Open source components"));
foreach(Attribution attribution in app.Attributions)
{
var line = new TextBlock { TextWrapping = TextWrapping.Wrap, FontSize = 12, Margin = new Thickness(0, 4, 0, 0) };
line.Inlines.Add(new Run(attribution.Name) { FontWeight = FontWeights.SemiBold });
line.Inlines.Add(new Run(" — " + attribution.License));
body.Children.Add(line);
}
}
var close = new Button { Content = "Close", IsDefault = true, IsCancel = true, MinWidth = 88, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 20, 0, 0) };
body.Children.Add(close);
var close = new Button { Content = "Close", IsDefault = true, IsCancel = true, MinWidth = 88, HorizontalAlignment = HorizontalAlignment.Right, Margin = new Thickness(0, 20, 0, 0) };
body.Children.Add(close);
Content = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Content = body };
}
Content = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Content = body };
}
/// <summary>Shows the About window modally over <paramref name="owner"/>.</summary>
public static void Show(AppInfo app, Window? owner, Assembly? versionAssembly = null)
=> _ = new AboutWindow(app, versionAssembly) { Owner = owner }.ShowDialog();
private static Border Header(AppInfo app, Assembly? assembly)
{
var title = new StackPanel { Orientation = Orientation.Horizontal };
if (Brand.Icon(app.Brand) is { } icon)
{
title.Children.Add(new Image { Width = 40, Height = 40, Margin = new Thickness(0, 0, 12, 0), Source = icon });
}
TextBlock name = Brand.Text(app.DisplayName, 28, strong: true);
name.FontFamily = new FontFamily("Segoe UI Variable Display, Segoe UI");
name.VerticalAlignment = VerticalAlignment.Center;
title.Children.Add(name);
var stack = new StackPanel { Margin = new Thickness(20, 16, 20, 16) };
stack.Children.Add(title);
if (app.Tagline.Length > 0)
{
TextBlock tagline = Brand.Text(app.Tagline, 13, 0.8);
tagline.HorizontalAlignment = HorizontalAlignment.Left;
stack.Children.Add(tagline);
}
if (assembly is not null)
{
TextBlock version = Brand.Text("Version " + BuildVersion.Describe(assembly), 12, 0.7);
version.HorizontalAlignment = HorizontalAlignment.Left;
AutomationProperties.SetAutomationId(version, "AboutVersionText");
stack.Children.Add(version);
}
/// <summary>Shows the About window modally over <paramref name="owner"/>.</summary>
public static void Show(AppInfo app, Window? owner, Assembly? versionAssembly = null)
=> _ = new AboutWindow(app, versionAssembly) { Owner = owner }.ShowDialog();
return new Border { CornerRadius = new CornerRadius(8), Background = Brand.GradientBrush(app.Brand), Child = stack };
}
private static TextBlock Heading(string text)
=> new() { Text = text, FontWeight = FontWeights.SemiBold, Margin = new Thickness(0, 16, 0, 0) };
private static TextBlock Paragraph(string text, double top = 12)
=> new() { Text = text, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, top, 0, 0) };
/// <summary>The brand header: the splash look in miniature, with the name, tagline, and build version.</summary>
private static Border Header(AppInfo app, Assembly? assembly)
{
var title = new StackPanel { Orientation = Orientation.Horizontal };
if(Brand.Icon(app.Brand) is { } icon)
{
title.Children.Add(new Image { Width = 40, Height = 40, Margin = new Thickness(0, 0, 12, 0), Source = icon });
}
TextBlock name = Brand.Text(app.DisplayName, 28, strong: true);
name.FontFamily = new FontFamily("Segoe UI Variable Display, Segoe UI");
name.VerticalAlignment = VerticalAlignment.Center;
title.Children.Add(name);
var stack = new StackPanel { Margin = new Thickness(20, 16, 20, 16) };
stack.Children.Add(title);
if(app.Tagline.Length > 0)
{
TextBlock tagline = Brand.Text(app.Tagline, 13, 0.8);
tagline.HorizontalAlignment = HorizontalAlignment.Left;
stack.Children.Add(tagline);
}
if(assembly is not null)
{
TextBlock version = Brand.Text("Version " + BuildVersion.Describe(assembly), 12, 0.7);
version.HorizontalAlignment = HorizontalAlignment.Left;
AutomationProperties.SetAutomationId(version, "AboutVersionText");
stack.Children.Add(version);
}
return new Border { CornerRadius = new CornerRadius(8), Background = Brand.GradientBrush(app.Brand), Child = stack };
}
/// <summary>A bold section heading.</summary>
private static TextBlock Heading(string text)
=> new() { Text = text, FontWeight = FontWeights.SemiBold, Margin = new Thickness(0, 16, 0, 0) };
/// <summary>A wrapped paragraph with the given space above it.</summary>
private static TextBlock Paragraph(string text, double top = 12)
=> new() { Text = text, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, top, 0, 0) };
}
+56 -44
View File
@@ -1,60 +1,72 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace KofTwentyTwo.AppKit.Wpf;
/// <summary>Turns an <see cref="AppBrand"/> into WPF brushes and images.</summary>
public static class Brand
{
/// <summary>The WPF color for <paramref name="color"/>.</summary>
public static Color ToColor(this ArgbColor color) => Color.FromArgb(color.A, color.R, color.G, color.B);
/// <summary>The WPF color for <paramref name="color"/>.</summary>
public static Color ToColor(this ArgbColor color) => Color.FromArgb(color.A, color.R, color.G, color.B);
/// <summary>The brand's diagonal gradient.</summary>
public static LinearGradientBrush GradientBrush(AppBrand brand)
{
ArgumentNullException.ThrowIfNull(brand);
var brush = new LinearGradientBrush(ArgbColor.Parse(brand.GradientStart).ToColor(), ArgbColor.Parse(brand.GradientEnd).ToColor(), new Point(0, 0), new Point(1, 1));
brush.Freeze();
return brush;
}
/// <summary>
/// The brand mark from the app's .ico (WPF has no native SVG support): the largest
/// frame, so it stays crisp when scaled. Null when the file is missing or unreadable.
/// </summary>
public static BitmapSource? Icon(AppBrand brand)
{
ArgumentNullException.ThrowIfNull(brand);
string path = Path.Combine(AppContext.BaseDirectory, brand.IconIcoPath);
if (!File.Exists(path))
{
return null;
}
try
{
var decoder = BitmapDecoder.Create(new Uri(path), BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
return decoder.Frames.OrderByDescending(frame => frame.PixelWidth).First();
}
catch
{
return null;
}
}
/// <summary>The brand's diagonal gradient.</summary>
public static LinearGradientBrush GradientBrush(AppBrand brand)
{
ArgumentNullException.ThrowIfNull(brand);
var brush = new LinearGradientBrush(ArgbColor.Parse(brand.GradientStart).ToColor(), ArgbColor.Parse(brand.GradientEnd).ToColor(), new Point(0, 0), new Point(1, 1));
brush.Freeze();
return brush;
}
/// <summary>White text at the given opacity over the brand gradient.</summary>
internal static TextBlock Text(string text, double size, double opacity = 1, bool strong = false)
=> new()
{
Text = text,
FontSize = size,
Foreground = Brushes.White,
Opacity = opacity,
FontWeight = strong ? FontWeights.SemiBold : FontWeights.Normal,
TextWrapping = TextWrapping.Wrap,
HorizontalAlignment = HorizontalAlignment.Center,
};
/// <summary>
/// The brand mark from the app's .ico (WPF has no native SVG support): the largest
/// frame, so it stays crisp when scaled. Null when the file is missing or unreadable.
/// </summary>
public static BitmapSource? Icon(AppBrand brand)
{
ArgumentNullException.ThrowIfNull(brand);
string path = Path.Combine(AppContext.BaseDirectory, brand.IconIcoPath);
if(!File.Exists(path))
{
return null;
}
try
{
var decoder = BitmapDecoder.Create(new Uri(path), BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
return decoder.Frames.OrderByDescending(frame => frame.PixelWidth).First();
}
catch
{
return null;
}
}
/// <summary>White text at the given opacity over the brand gradient.</summary>
internal static TextBlock Text(string text, double size, double opacity = 1, bool strong = false)
=> new()
{
Text = text,
FontSize = size,
Foreground = Brushes.White,
Opacity = opacity,
FontWeight = strong ? FontWeights.SemiBold : FontWeights.Normal,
TextWrapping = TextWrapping.Wrap,
HorizontalAlignment = HorizontalAlignment.Center,
};
}
@@ -1,28 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Views are built in code, mirroring the WinUI package, so both packages expose the
same shapes and an app can switch frameworks without relearning AppKit. -->
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<IsPackable>true</IsPackable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Description>WPF shell pieces for KofTwentyTwo Windows apps: branded splash overlay, About window, live log viewer, crash safety net, Fluent theme switching, and the update-dialog adapter.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageTags>$(PackageTags);fluent</PackageTags>
<!-- WPF0001: Application.ThemeMode (the Fluent theme) is marked experimental in
.NET 9/10. It is the supported way to get Windows 11 styling and light/dark
switching in WPF, and this package isolates the one call that uses it. -->
<NoWarn>$(NoWarn);WPF0001</NoWarn>
</PropertyGroup>
<!-- Views are built in code, mirroring the WinUI package, so both packages expose the
same shapes and an app can switch frameworks without relearning AppKit. -->
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<IsPackable>true</IsPackable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Description>WPF shell pieces for KofTwentyTwo Windows apps: branded splash overlay, About window, live log viewer, crash safety net, Fluent theme switching, and the update-dialog adapter.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageTags>$(PackageTags);fluent</PackageTags>
<!-- WPF0001: Application.ThemeMode (the Fluent theme) is marked experimental in
.NET 9/10. It is the supported way to get Windows 11 styling and light/dark
switching in WPF, and this package isolates the one call that uses it. -->
<NoWarn>$(NoWarn);WPF0001</NoWarn>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\KofTwentyTwo.AppKit\KofTwentyTwo.AppKit.csproj" />
<ProjectReference Include="..\KofTwentyTwo.AppKit.Updates\KofTwentyTwo.AppKit.Updates.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\KofTwentyTwo.AppKit\KofTwentyTwo.AppKit.csproj" />
<ProjectReference Include="..\KofTwentyTwo.AppKit.Updates\KofTwentyTwo.AppKit.Updates.csproj" />
</ItemGroup>
</Project>
+109 -91
View File
@@ -1,3 +1,8 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics;
using System.IO;
using System.Windows;
@@ -7,6 +12,7 @@ using System.Windows.Media;
using System.Windows.Threading;
using KofTwentyTwo.AppKit.Logging;
namespace KofTwentyTwo.AppKit.Wpf;
/// <summary>
@@ -16,108 +22,120 @@ namespace KofTwentyTwo.AppKit.Wpf;
/// </summary>
public sealed class LogWindow : Window
{
private static LogWindow? s_current;
private static LogWindow? s_current;
private readonly IActivityLog _log;
private readonly DispatcherTimer _refresh = new() { Interval = TimeSpan.FromSeconds(1) };
private readonly TextBlock _pathText = new() { VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, Opacity = 0.7, Margin = new Thickness(8, 0, 0, 0) };
private readonly TextBox _logText = new() { FontFamily = new FontFamily("Cascadia Mono, Consolas"), FontSize = 12, IsReadOnly = true, BorderThickness = new Thickness(0), HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Padding = new Thickness(12) };
private readonly ToggleButton _follow = new() { Content = "Follow", IsChecked = true, Padding = new Thickness(10, 4, 10, 4) };
private readonly ToggleButton _errorsOnly = new() { Content = "Errors only", Padding = new Thickness(10, 4, 10, 4), Margin = new Thickness(8, 0, 0, 0) };
private readonly IActivityLog _log;
private readonly DispatcherTimer _refresh = new() { Interval = TimeSpan.FromSeconds(1) };
private readonly TextBlock _pathText = new() { VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis, Opacity = 0.7, Margin = new Thickness(8, 0, 0, 0) };
private readonly TextBox _logText = new() { FontFamily = new FontFamily("Cascadia Mono, Consolas"), FontSize = 12, IsReadOnly = true, BorderThickness = new Thickness(0), HorizontalScrollBarVisibility = ScrollBarVisibility.Auto, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, Padding = new Thickness(12) };
private readonly ToggleButton _follow = new() { Content = "Follow", IsChecked = true, Padding = new Thickness(10, 4, 10, 4) };
private readonly ToggleButton _errorsOnly = new() { Content = "Errors only", Padding = new Thickness(10, 4, 10, 4), Margin = new Thickness(8, 0, 0, 0) };
private long _lastLength = -1;
private long _lastLength = -1;
/// <summary>Creates a log window over <paramref name="log"/>.</summary>
public LogWindow(IActivityLog log, AppInfo app)
{
ArgumentNullException.ThrowIfNull(log);
ArgumentNullException.ThrowIfNull(app);
_log = log;
Title = app.DisplayName + " — Activity log";
Width = 1000;
Height = 680;
this.SetAppIcon(app);
var copy = new Button { Content = "Copy", Padding = new Thickness(10, 4, 10, 4), Margin = new Thickness(8, 0, 0, 0) };
var openFolder = new Button { Content = "Open logs folder", Padding = new Thickness(10, 4, 10, 4), Margin = new Thickness(8, 0, 0, 0) };
_errorsOnly.Click += (_, _) => Reload();
copy.Click += (_, _) => Clipboard.SetText(_logText.Text);
openFolder.Click += (_, _) =>
{
if (Directory.Exists(_log.LogDirectory))
{
Process.Start(new ProcessStartInfo(_log.LogDirectory) { UseShellExecute = true });
}
};
var toolbar = new DockPanel { Margin = new Thickness(12, 12, 12, 4), LastChildFill = true };
var buttons = new StackPanel { Orientation = Orientation.Horizontal };
buttons.Children.Add(_follow);
buttons.Children.Add(_errorsOnly);
buttons.Children.Add(copy);
buttons.Children.Add(openFolder);
DockPanel.SetDock(buttons, Dock.Left);
toolbar.Children.Add(buttons);
toolbar.Children.Add(_pathText);
/// <summary>Creates a log window over <paramref name="log"/>.</summary>
public LogWindow(IActivityLog log, AppInfo app)
{
ArgumentNullException.ThrowIfNull(log);
ArgumentNullException.ThrowIfNull(app);
_log = log;
Title = app.DisplayName + " — Activity log";
Width = 1000;
Height = 680;
this.SetAppIcon(app);
var root = new DockPanel();
DockPanel.SetDock(toolbar, Dock.Top);
root.Children.Add(toolbar);
root.Children.Add(_logText);
Content = root;
var copy = new Button { Content = "Copy", Padding = new Thickness(10, 4, 10, 4), Margin = new Thickness(8, 0, 0, 0) };
var openFolder = new Button { Content = "Open logs folder", Padding = new Thickness(10, 4, 10, 4), Margin = new Thickness(8, 0, 0, 0) };
_errorsOnly.Click += (_, _) => Reload();
copy.Click += (_, _) => Clipboard.SetText(_logText.Text);
openFolder.Click += (_, _) =>
{
if(Directory.Exists(_log.LogDirectory))
{
Process.Start(new ProcessStartInfo(_log.LogDirectory) { UseShellExecute = true });
}
};
_refresh.Tick += (_, _) => RefreshIfChanged();
_refresh.Start();
Closed += (_, _) => _refresh.Stop();
var toolbar = new DockPanel { Margin = new Thickness(12, 12, 12, 4), LastChildFill = true };
var buttons = new StackPanel { Orientation = Orientation.Horizontal };
buttons.Children.Add(_follow);
buttons.Children.Add(_errorsOnly);
buttons.Children.Add(copy);
buttons.Children.Add(openFolder);
DockPanel.SetDock(buttons, Dock.Left);
toolbar.Children.Add(buttons);
toolbar.Children.Add(_pathText);
Reload();
}
var root = new DockPanel();
DockPanel.SetDock(toolbar, Dock.Top);
root.Children.Add(toolbar);
root.Children.Add(_logText);
Content = root;
/// <summary>Activates the open log window, or opens one.</summary>
public static LogWindow ShowSingle(IActivityLog log, AppInfo app)
{
if (s_current is null)
{
s_current = new LogWindow(log, app);
s_current.Closed += (_, _) => s_current = null;
s_current.Show();
}
s_current.Activate();
return s_current;
}
_refresh.Tick += (_, _) => RefreshIfChanged();
_refresh.Start();
Closed += (_, _) => _refresh.Stop();
/// <summary>Closes the window <see cref="ShowSingle"/> opened, if any.</summary>
public static void CloseCurrent() => s_current?.Close();
Reload();
}
private void RefreshIfChanged()
{
if (_follow.IsChecked != true)
{
return;
}
try
{
string path = _log.CurrentLogFilePath;
long length = File.Exists(path) ? new FileInfo(path).Length : 0;
if (length != _lastLength)
{
Reload();
}
}
catch
{
// Diagnostics UI must never take the app down over an unreadable file.
}
}
private void Reload()
{
_pathText.Text = _log.CurrentLogFilePath;
LogSnapshot snapshot = LogTail.Read(_log.CurrentLogFilePath);
_lastLength = snapshot.Length;
bool errorsOnly = _errorsOnly.IsChecked == true;
_logText.Text = LogTail.ToDisplayText(errorsOnly ? LogTail.ErrorsOnly(snapshot.Lines) : snapshot.Lines, errorsOnly);
_logText.ScrollToEnd();
}
/// <summary>Activates the open log window, or opens one.</summary>
public static LogWindow ShowSingle(IActivityLog log, AppInfo app)
{
if(s_current is null)
{
s_current = new LogWindow(log, app);
s_current.Closed += (_, _) => s_current = null;
s_current.Show();
}
s_current.Activate();
return s_current;
}
/// <summary>Closes the window <see cref="ShowSingle"/> opened, if any.</summary>
public static void CloseCurrent() => s_current?.Close();
/// <summary>Timer tick: reloads only when Follow is on and the log file grew or rolled over to a new day.</summary>
private void RefreshIfChanged()
{
if(_follow.IsChecked != true)
{
return;
}
try
{
string path = _log.CurrentLogFilePath;
long length = File.Exists(path) ? new FileInfo(path).Length : 0;
if(length != _lastLength)
{
Reload();
}
}
catch
{
// Diagnostics UI must never take the app down over an unreadable file.
}
}
/// <summary>Rereads the log tail, applies the errors-only filter, and scrolls to the newest entry.</summary>
private void Reload()
{
_pathText.Text = _log.CurrentLogFilePath;
LogSnapshot snapshot = LogTail.Read(_log.CurrentLogFilePath);
_lastLength = snapshot.Length;
bool errorsOnly = _errorsOnly.IsChecked == true;
_logText.Text = LogTail.ToDisplayText(errorsOnly ? LogTail.ErrorsOnly(snapshot.Lines) : snapshot.Lines, errorsOnly);
_logText.ScrollToEnd();
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Windows;
using KofTwentyTwo.AppKit.Interaction;
namespace KofTwentyTwo.AppKit.Wpf;
/// <summary>
/// <see cref="IUserPrompter"/> over WPF message boxes, for shared flows such as
/// <see cref="Updates.UpdateCoordinator"/>. Message boxes cannot relabel their buttons,
/// so confirmations show Yes/No with the confirm text folded into the question.
/// </summary>
/// <param name="owner">Supplies the owning window, usually the main window.</param>
public sealed class MessageBoxPrompter(Func<Window?> owner) : IUserPrompter
{
/// <inheritdoc/>
public Task ShowMessageAsync(string title, string message)
{
Show(message, title, MessageBoxButton.OK, MessageBoxImage.Information);
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task<bool> ConfirmAsync(string title, string message, string confirmText, string cancelText)
{
MessageBoxResult result = Show($"{message}\n\n{confirmText}?", title, MessageBoxButton.YesNo, MessageBoxImage.Question);
return Task.FromResult(result == MessageBoxResult.Yes);
}
/// <summary>Shows a message box owned by the app&apos;s window when there is one, so it stays in front of the app.</summary>
private MessageBoxResult Show(string message, string title, MessageBoxButton buttons, MessageBoxImage image)
=> owner() is Window window
? MessageBox.Show(window, message, title, buttons, image)
: MessageBox.Show(message, title, buttons, image);
}
+130 -108
View File
@@ -1,3 +1,8 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Reflection;
using System.Windows;
using System.Windows.Automation;
@@ -6,6 +11,7 @@ using System.Windows.Media;
using System.Windows.Media.Animation;
using KofTwentyTwo.AppKit.Settings;
namespace KofTwentyTwo.AppKit.Wpf;
/// <summary>
@@ -15,123 +21,139 @@ namespace KofTwentyTwo.AppKit.Wpf;
/// </summary>
public sealed class SplashOverlay : UserControl
{
private bool _dismissed;
private bool _dismissed;
/// <summary>Builds the overlay for <paramref name="app"/>.</summary>
/// <param name="app">Whose splash this is.</param>
/// <param name="versionAssembly">Assembly whose build identity is shown; the entry assembly when null.</param>
public SplashOverlay(AppInfo app, Assembly? versionAssembly = null)
{
ArgumentNullException.ThrowIfNull(app);
AutomationProperties.SetName(this, $"{app.DisplayName} is starting");
AutomationProperties.SetAutomationId(this, "AppKitSplash");
var root = new Grid { Background = Brand.GradientBrush(app.Brand) };
var center = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
if (Brand.Icon(app.Brand) is { } icon)
{
center.Children.Add(new Image { Width = 96, Height = 96, Margin = new Thickness(0, 0, 0, 16), Source = icon });
}
TextBlock title = Brand.Text(app.DisplayName, 40, strong: true);
title.FontFamily = new FontFamily("Segoe UI Variable Display, Segoe UI");
center.Children.Add(title);
if (app.Tagline.Length > 0)
{
TextBlock tagline = Brand.Text(app.Tagline, 14, 0.8);
tagline.Margin = new Thickness(0, 4, 0, 0);
center.Children.Add(tagline);
}
Assembly? assembly = versionAssembly ?? Assembly.GetEntryAssembly();
if (assembly is not null)
{
TextBlock version = Brand.Text(BuildVersion.Describe(assembly), 12, 0.6);
version.Margin = new Thickness(0, 8, 0, 0);
center.Children.Add(version);
}
root.Children.Add(center);
/// <summary>Builds the overlay for <paramref name="app"/>.</summary>
/// <param name="app">Whose splash this is.</param>
/// <param name="versionAssembly">Assembly whose build identity is shown; the entry assembly when null.</param>
public SplashOverlay(AppInfo app, Assembly? versionAssembly = null)
{
ArgumentNullException.ThrowIfNull(app);
AutomationProperties.SetName(this, $"{app.DisplayName} is starting");
AutomationProperties.SetAutomationId(this, "AppKitSplash");
string contact = Credits.ContactLine(app);
if (app.Author.Length > 0 || contact.Length > 0)
{
var credit = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Bottom, Margin = new Thickness(0, 0, 0, 44) };
if (app.Author.Length > 0)
{
credit.Children.Add(Brand.Text("Created by " + app.Author, 13, 0.85, strong: true));
}
if (contact.Length > 0)
{
credit.Children.Add(Brand.Text(contact, 12, 0.6));
}
root.Children.Add(credit);
}
var root = new Grid { Background = Brand.GradientBrush(app.Brand) };
root.Children.Add(new ProgressBar
{
IsIndeterminate = true,
Height = 3,
Foreground = Brushes.White,
Background = new SolidColorBrush(ArgbColor.Parse("#33FFFFFF").ToColor()),
BorderThickness = new Thickness(0),
VerticalAlignment = VerticalAlignment.Bottom,
Margin = new Thickness(0, 0, 0, 20),
});
var center = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
if(Brand.Icon(app.Brand) is { } icon)
{
center.Children.Add(new Image { Width = 96, Height = 96, Margin = new Thickness(0, 0, 0, 16), Source = icon });
}
TextBlock title = Brand.Text(app.DisplayName, 40, strong: true);
title.FontFamily = new FontFamily("Segoe UI Variable Display, Segoe UI");
center.Children.Add(title);
if(app.Tagline.Length > 0)
{
TextBlock tagline = Brand.Text(app.Tagline, 14, 0.8);
tagline.Margin = new Thickness(0, 4, 0, 0);
center.Children.Add(tagline);
}
Assembly? assembly = versionAssembly ?? Assembly.GetEntryAssembly();
if(assembly is not null)
{
TextBlock version = Brand.Text(BuildVersion.Describe(assembly), 12, 0.6);
version.Margin = new Thickness(0, 8, 0, 0);
center.Children.Add(version);
}
root.Children.Add(center);
Content = root;
}
string contact = Credits.ContactLine(app);
if(app.Author.Length > 0 || contact.Length > 0)
{
var credit = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Bottom, Margin = new Thickness(0, 0, 0, 44) };
if(app.Author.Length > 0)
{
credit.Children.Add(Brand.Text("Created by " + app.Author, 13, 0.85, strong: true));
}
if(contact.Length > 0)
{
credit.Children.Add(Brand.Text(contact, 12, 0.6));
}
root.Children.Add(credit);
}
/// <summary>
/// Shows a splash over <paramref name="host"/> (the window's root Grid) when
/// <paramref name="settings"/> enables it, and dismisses it after the configured
/// time. Returns the overlay, or null when the splash is turned off.
/// </summary>
public static SplashOverlay? ShowOver(Panel host, AppInfo app, ShellSettings settings, Assembly? versionAssembly = null)
{
ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(settings);
if (!settings.ShowSplashScreen)
{
return null;
}
root.Children.Add(new ProgressBar
{
IsIndeterminate = true,
Height = 3,
Foreground = Brushes.White,
Background = new SolidColorBrush(ArgbColor.Parse("#33FFFFFF").ToColor()),
BorderThickness = new Thickness(0),
VerticalAlignment = VerticalAlignment.Bottom,
Margin = new Thickness(0, 0, 0, 20),
});
var overlay = new SplashOverlay(app, versionAssembly);
if (host is Grid grid)
{
Grid.SetRowSpan(overlay, Math.Max(grid.RowDefinitions.Count, 1));
Grid.SetColumnSpan(overlay, Math.Max(grid.ColumnDefinitions.Count, 1));
}
host.Children.Add(overlay);
overlay.Dispatcher.InvokeAsync(async () =>
{
await Task.Delay(settings.SplashMilliseconds);
await overlay.DismissAsync();
});
return overlay;
}
Content = root;
}
/// <summary>
/// Fades the overlay out and removes it from its parent panel. Safe to call more
/// than once; only the first call does anything.
/// </summary>
public Task DismissAsync()
{
if (_dismissed)
{
return Task.CompletedTask;
}
_dismissed = true;
// Input must fall through to the app the moment dismissal starts.
IsHitTestVisible = false;
var completion = new TaskCompletionSource();
var fade = new DoubleAnimation(0, new Duration(TimeSpan.FromMilliseconds(250)));
fade.Completed += (_, _) =>
{
(Parent as Panel)?.Children.Remove(this);
completion.TrySetResult();
};
BeginAnimation(OpacityProperty, fade);
return completion.Task;
}
/// <summary>
/// Shows a splash over <paramref name="host"/> (the window's root Grid) when
/// <paramref name="settings"/> enables it, and dismisses it after the configured
/// time. Returns the overlay, or null when the splash is turned off.
/// </summary>
public static SplashOverlay? ShowOver(Panel host, AppInfo app, ShellSettings settings, Assembly? versionAssembly = null)
{
ArgumentNullException.ThrowIfNull(host);
ArgumentNullException.ThrowIfNull(settings);
if(!settings.ShowSplashScreen)
{
return null;
}
var overlay = new SplashOverlay(app, versionAssembly);
if(host is Grid grid)
{
Grid.SetRowSpan(overlay, Math.Max(grid.RowDefinitions.Count, 1));
Grid.SetColumnSpan(overlay, Math.Max(grid.ColumnDefinitions.Count, 1));
}
host.Children.Add(overlay);
///////////////////////////////////////////////////////////////////////////////
// start the countdown through the dispatcher, not directly: an app may call //
// this before Application.Run, when no synchronization context exists yet, //
// and the fade must run on the UI thread //
///////////////////////////////////////////////////////////////////////////////
_ = overlay.Dispatcher.InvokeAsync(() => overlay.DismissAfterAsync(settings.SplashMilliseconds));
return overlay;
}
/// <summary>Waits <paramref name="milliseconds"/>, then fades the overlay out.</summary>
private async Task DismissAfterAsync(int milliseconds)
{
await Task.Delay(milliseconds);
await DismissAsync();
}
/// <summary>
/// Fades the overlay out and removes it from its parent panel. Safe to call more
/// than once; only the first call does anything.
/// </summary>
public Task DismissAsync()
{
if(_dismissed)
{
return Task.CompletedTask;
}
_dismissed = true;
// Input must fall through to the app the moment dismissal starts.
IsHitTestVisible = false;
var completion = new TaskCompletionSource();
var fade = new DoubleAnimation(0, new Duration(TimeSpan.FromMilliseconds(250)));
fade.Completed += (_, _) =>
{
(Parent as Panel)?.Children.Remove(this);
completion.TrySetResult();
};
BeginAnimation(OpacityProperty, fade);
return completion.Task;
}
}
+67 -83
View File
@@ -1,101 +1,85 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.IO;
using System.Windows;
using System.Windows.Media.Imaging;
using KofTwentyTwo.AppKit.Interaction;
using KofTwentyTwo.AppKit.Logging;
using KofTwentyTwo.AppKit.Settings;
namespace KofTwentyTwo.AppKit.Wpf;
/// <summary>App-level wiring every WPF app repeats: crash net and theme.</summary>
public static class WpfShell
{
/// <summary>
/// Last-resort net: records exceptions that escape UI event handlers and keeps the
/// app running, and records unobserved task exceptions. Call from App startup.
/// </summary>
/// <param name="application">The app.</param>
/// <param name="log">Supplies the log; may return null early in startup.</param>
public static void InstallCrashNet(Application application, Func<IActivityLog?> log)
{
ArgumentNullException.ThrowIfNull(application);
ArgumentNullException.ThrowIfNull(log);
application.DispatcherUnhandledException += (_, e) =>
{
e.Handled = true;
SafeLog(log, "Unhandled UI exception", e.Exception);
};
TaskScheduler.UnobservedTaskException += (_, e) =>
{
e.SetObserved();
SafeLog(log, "Unobserved task exception", e.Exception);
};
}
/// <summary>
/// Last-resort net: records exceptions that escape UI event handlers and keeps the
/// app running, and records unobserved task exceptions. Call from App startup.
/// </summary>
/// <param name="application">The app.</param>
/// <param name="log">Supplies the log; may return null early in startup.</param>
public static void InstallCrashNet(Application application, Func<IActivityLog?> log)
{
ArgumentNullException.ThrowIfNull(application);
ArgumentNullException.ThrowIfNull(log);
application.DispatcherUnhandledException += (_, e) =>
{
e.Handled = true;
SafeLog(log, "Unhandled UI exception", e.Exception);
};
TaskScheduler.UnobservedTaskException += (_, e) =>
{
e.SetObserved();
SafeLog(log, "Unobserved task exception", e.Exception);
};
}
/// <summary>
/// Applies the Windows 11 Fluent theme in the requested mode. Safe to call at any
/// time; WPF restyles open windows live.
/// </summary>
public static void ApplyTheme(Application application, AppTheme theme)
{
ArgumentNullException.ThrowIfNull(application);
application.ThemeMode = theme switch
{
AppTheme.Light => ThemeMode.Light,
AppTheme.Dark => ThemeMode.Dark,
_ => ThemeMode.System,
};
}
/// <summary>Sets the brand icon on a window. A missing .ico is ignored.</summary>
public static void SetAppIcon(this Window window, AppInfo app)
{
ArgumentNullException.ThrowIfNull(window);
ArgumentNullException.ThrowIfNull(app);
string path = Path.Combine(AppContext.BaseDirectory, app.Brand.IconIcoPath);
if (File.Exists(path))
{
window.Icon = BitmapFrame.Create(new Uri(path));
}
}
private static void SafeLog(Func<IActivityLog?> log, string message, Exception exception)
{
try
{
log()?.Error($"{message}: {exception.Message}", exception);
}
catch
{
// Logging must never turn a survivable error fatal.
}
}
}
/// <summary>
/// Applies the Windows 11 Fluent theme in the requested mode. Safe to call at any
/// time; WPF restyles open windows live.
/// </summary>
public static void ApplyTheme(Application application, AppTheme theme)
{
ArgumentNullException.ThrowIfNull(application);
application.ThemeMode = theme switch
{
AppTheme.Light => ThemeMode.Light,
AppTheme.Dark => ThemeMode.Dark,
_ => ThemeMode.System,
};
}
/// <summary>
/// <see cref="IUserPrompter"/> over WPF message boxes, for shared flows such as
/// <see cref="Updates.UpdateCoordinator"/>. Message boxes cannot relabel their buttons,
/// so confirmations show Yes/No with the confirm text folded into the question.
/// </summary>
/// <param name="owner">Supplies the owning window, usually the main window.</param>
public sealed class MessageBoxPrompter(Func<Window?> owner) : IUserPrompter
{
/// <inheritdoc/>
public Task ShowMessageAsync(string title, string message)
{
Show(message, title, MessageBoxButton.OK, MessageBoxImage.Information);
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task<bool> ConfirmAsync(string title, string message, string confirmText, string cancelText)
{
MessageBoxResult result = Show($"{message}\n\n{confirmText}?", title, MessageBoxButton.YesNo, MessageBoxImage.Question);
return Task.FromResult(result == MessageBoxResult.Yes);
}
private MessageBoxResult Show(string message, string title, MessageBoxButton buttons, MessageBoxImage image)
=> owner() is Window window
? MessageBox.Show(window, message, title, buttons, image)
: MessageBox.Show(message, title, buttons, image);
/// <summary>Sets the brand icon on a window. A missing .ico is ignored.</summary>
public static void SetAppIcon(this Window window, AppInfo app)
{
ArgumentNullException.ThrowIfNull(window);
ArgumentNullException.ThrowIfNull(app);
string path = Path.Combine(AppContext.BaseDirectory, app.Brand.IconIcoPath);
if(File.Exists(path))
{
window.Icon = BitmapFrame.Create(new Uri(path));
}
}
/// <summary>Records an exception caught by the crash net; a failure of the logger itself is swallowed so logging can never make an error fatal.</summary>
private static void SafeLog(Func<IActivityLog?> log, string message, Exception exception)
{
try
{
log()?.Error($"{message}: {exception.Message}", exception);
}
catch
{
// Logging must never turn a survivable error fatal.
}
}
}
@@ -0,0 +1,29 @@
{
"version": 2,
"dependencies": {
"net10.0-windows7.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.296, )",
"resolved": "3.0.296",
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
},
"koftwentytwo.appkit": {
"type": "Project"
},
"koftwentytwo.appkit.updates": {
"type": "Project",
"dependencies": {
"KofTwentyTwo.AppKit": "[0.1.0-dev, )",
"Velopack": "[1.2.161, )"
}
},
"Velopack": {
"type": "CentralTransitive",
"requested": "[1.2.161, )",
"resolved": "1.2.161",
"contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace KofTwentyTwo.AppKit;
/// <summary>
/// Brand look for the splash and About header: a diagonal two-stop gradient behind
/// white type, plus the app-relative paths of the vector mark and window icon.
/// </summary>
public sealed record AppBrand
{
/// <summary>Top-left gradient color, "#RRGGBB" or "#AARRGGBB".</summary>
public string GradientStart { get; init; } = "#3B4CCA";
/// <summary>Bottom-right gradient color, "#RRGGBB" or "#AARRGGBB".</summary>
public string GradientEnd { get; init; } = "#2A2F8F";
/// <summary>Vector mark, relative to the app directory.</summary>
public string IconSvgPath { get; init; } = "Assets/Brand/app-icon.svg";
/// <summary>Window and taskbar icon, relative to the app directory.</summary>
public string IconIcoPath { get; init; } = "Assets/Brand/app.ico";
}
+67 -76
View File
@@ -1,5 +1,11 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Text.RegularExpressions;
namespace KofTwentyTwo.AppKit;
/// <summary>
@@ -9,94 +15,79 @@ namespace KofTwentyTwo.AppKit;
/// </summary>
public sealed partial record AppInfo
{
private readonly string _id = "";
private readonly string _id = "";
/// <summary>
/// Machine name of the app: lowercase letters, digits, and dashes, starting with a
/// letter or digit (e.g. "gclo"). Used for the data folder, log file prefix,
/// credential target prefix, and the data-directory override variable.
/// </summary>
/// <exception cref="ArgumentException">The value is not a valid app id.</exception>
public required string Id
{
get => _id;
init
{
if (value is null || !IdPattern().IsMatch(value))
{
throw new ArgumentException(
$"App id '{value}' is invalid: use lowercase letters, digits, and dashes, starting with a letter or digit.",
nameof(Id));
}
_id = value;
}
}
/// <summary>Human-readable product name shown in titles and dialogs.</summary>
public required string DisplayName { get; init; }
/// <summary>Short subtitle shown under the name on the splash and About screens.</summary>
public string Tagline { get; init; } = "";
/// <summary>
/// Machine name of the app: lowercase letters, digits, and dashes, starting with a
/// letter or digit (e.g. "gclo"). Used for the data folder, log file prefix,
/// credential target prefix, and the data-directory override variable.
/// </summary>
/// <exception cref="ArgumentException">The value is not a valid app id.</exception>
public required string Id
{
get => _id;
init
{
if(value is null || !IdPattern().IsMatch(value))
{
throw new ArgumentException(
$"App id '{value}' is invalid: use lowercase letters, digits, and dashes, starting with a letter or digit.",
nameof(value));
}
_id = value;
}
}
/// <summary>One- or two-sentence description shown in the About screen.</summary>
public string Description { get; init; } = "";
/// <summary>Public source repository; also the default self-update feed.</summary>
public Uri? RepositoryUrl { get; init; }
/// <summary>Author name shown in credits.</summary>
public string Author { get; init; } = "";
/// <summary>Human-readable product name shown in titles and dialogs.</summary>
public required string DisplayName { get; init; }
/// <summary>Author contact address shown in credits.</summary>
public string AuthorEmail { get; init; } = "";
/// <summary>Short subtitle shown under the name on the splash and About screens.</summary>
public string Tagline { get; init; } = "";
/// <summary>Author or product website shown in credits.</summary>
public Uri? Website { get; init; }
/// <summary>One- or two-sentence description shown in the About screen.</summary>
public string Description { get; init; } = "";
/// <summary>License name of the app itself, e.g. "MIT License".</summary>
public string License { get; init; } = "MIT License";
/// <summary>Public source repository; also the default self-update feed.</summary>
public Uri? RepositoryUrl { get; init; }
/// <summary>Copyright line, e.g. "Copyright (c) 2026 James Maes".</summary>
public string Copyright { get; init; } = "";
/// <summary>Author name shown in credits.</summary>
public string Author { get; init; } = "";
/// <summary>Colors and icon locations for branded surfaces.</summary>
public AppBrand Brand { get; init; } = new();
/// <summary>Author contact address shown in credits.</summary>
public string AuthorEmail { get; init; } = "";
/// <summary>Third-party components credited in the About screen.</summary>
public IReadOnlyList<Attribution> Attributions { get; init; } = [];
/// <summary>Author or product website shown in credits.</summary>
public Uri? Website { get; init; }
/// <summary>
/// Environment variable that relocates the app's per-user data folder, e.g.
/// "GCLO_DATA_DIR" for id "gclo". UI tests and portable setups use it to isolate
/// the app from the real profile.
/// </summary>
public string DataDirectoryVariable
=> Id.ToUpperInvariant().Replace('-', '_') + "_DATA_DIR";
/// <summary>License name of the app itself, e.g. "MIT License".</summary>
public string License { get; init; } = "MIT License";
[GeneratedRegex("^[a-z0-9][a-z0-9-]*$")]
private static partial Regex IdPattern();
}
/// <summary>A third-party component credited in the About screen.</summary>
/// <param name="Name">Component name, e.g. "Velopack".</param>
/// <param name="License">License name, e.g. "MIT License".</param>
public sealed record Attribution(string Name, string License);
/// <summary>
/// Brand look for the splash and About header: a diagonal two-stop gradient behind
/// white type, plus the app-relative paths of the vector mark and window icon.
/// </summary>
public sealed record AppBrand
{
/// <summary>Top-left gradient color, "#RRGGBB" or "#AARRGGBB".</summary>
public string GradientStart { get; init; } = "#3B4CCA";
/// <summary>Bottom-right gradient color, "#RRGGBB" or "#AARRGGBB".</summary>
public string GradientEnd { get; init; } = "#2A2F8F";
/// <summary>Vector mark, relative to the app directory.</summary>
public string IconSvgPath { get; init; } = "Assets/Brand/app-icon.svg";
/// <summary>Window and taskbar icon, relative to the app directory.</summary>
public string IconIcoPath { get; init; } = "Assets/Brand/app.ico";
/// <summary>Copyright line, e.g. "Copyright (c) 2026 James Maes".</summary>
public string Copyright { get; init; } = "";
/// <summary>Colors and icon locations for branded surfaces.</summary>
public AppBrand Brand { get; init; } = new();
/// <summary>Third-party components credited in the About screen.</summary>
public IReadOnlyList<Attribution> Attributions { get; init; } = [];
/// <summary>
/// Environment variable that relocates the app's per-user data folder, e.g.
/// "GCLO_DATA_DIR" for id "gclo". UI tests and portable setups use it to isolate
/// the app from the real profile.
/// </summary>
public string DataDirectoryVariable
=> Id.ToUpperInvariant().Replace('-', '_') + "_DATA_DIR";
/// <summary>The app id grammar: lowercase letters, digits, and dashes, starting with a letter or digit. Source-generated, with a match timeout as a guard.</summary>
[GeneratedRegex("^[a-z0-9][a-z0-9-]*$", RegexOptions.None, matchTimeoutMilliseconds: 1000)]
private static partial Regex IdPattern();
}
+43 -28
View File
@@ -1,3 +1,8 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace KofTwentyTwo.AppKit;
/// <summary>
@@ -10,38 +15,48 @@ namespace KofTwentyTwo.AppKit;
/// </summary>
public sealed class AppPaths
{
private readonly AppInfo _app;
private readonly Func<string, string?> _getEnvironmentVariable;
private readonly AppInfo _app;
private readonly Func<string, string?> _getEnvironmentVariable;
/// <summary>Creates the paths for <paramref name="app"/>.</summary>
public AppPaths(AppInfo app)
: this(app, Environment.GetEnvironmentVariable)
{
}
/// <summary>Test seam: the environment lookup is injectable.</summary>
internal AppPaths(AppInfo app, Func<string, string?> getEnvironmentVariable)
{
ArgumentNullException.ThrowIfNull(app);
_app = app;
_getEnvironmentVariable = getEnvironmentVariable;
}
/// <summary>
/// Root of the app's per-user data. Read on every access so a value set before
/// launch always wins over any cached default.
/// </summary>
public string DataRoot
=> _getEnvironmentVariable(_app.DataDirectoryVariable) is { Length: > 0 } dir
? dir
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), _app.Id);
/// <summary>Creates the paths for <paramref name="app"/>.</summary>
public AppPaths(AppInfo app)
: this(app, Environment.GetEnvironmentVariable)
{
}
/// <summary>Folder for the daily activity log files.</summary>
public string LogsDirectory => Path.Combine(DataRoot, "logs");
/// <summary>The user-preferences file.</summary>
public string SettingsFile => Path.Combine(DataRoot, "settings.json");
/// <summary>Any other file under <see cref="DataRoot"/>, e.g. "accounts.json".</summary>
public string GetFile(string relativePath) => Path.Combine(DataRoot, relativePath);
/// <summary>Test seam: the environment lookup is injectable.</summary>
internal AppPaths(AppInfo app, Func<string, string?> getEnvironmentVariable)
{
ArgumentNullException.ThrowIfNull(app);
_app = app;
_getEnvironmentVariable = getEnvironmentVariable;
}
/// <summary>
/// Root of the app's per-user data. Read on every access so a value set before
/// launch always wins over any cached default.
/// </summary>
public string DataRoot
=> _getEnvironmentVariable(_app.DataDirectoryVariable) is { Length: > 0 } dir
? dir
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), _app.Id);
/// <summary>Folder for the daily activity log files.</summary>
public string LogsDirectory => Path.Combine(DataRoot, "logs");
/// <summary>The user-preferences file.</summary>
public string SettingsFile => Path.Combine(DataRoot, "settings.json");
/// <summary>Any other file under <see cref="DataRoot"/>, e.g. "accounts.json".</summary>
public string GetFile(string relativePath) => Path.Combine(DataRoot, relativePath);
}
+32 -22
View File
@@ -1,4 +1,11 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
using System.Runtime.InteropServices;
namespace KofTwentyTwo.AppKit;
@@ -6,30 +13,33 @@ namespace KofTwentyTwo.AppKit;
/// A framework-neutral color parsed from "#RRGGBB" or "#AARRGGBB", so brand colors can
/// be declared once in <see cref="AppBrand"/> and converted by each UI package.
/// </summary>
[StructLayout(LayoutKind.Auto)]
public readonly record struct ArgbColor(byte A, byte R, byte G, byte B)
{
/// <summary>Parses "#RRGGBB" (opaque) or "#AARRGGBB".</summary>
/// <exception cref="FormatException">The text is not a supported hex color.</exception>
public static ArgbColor Parse(string text)
=> TryParse(text, out ArgbColor color)
? color
: throw new FormatException($"'{text}' is not a color; expected #RRGGBB or #AARRGGBB.");
/// <summary>Parses "#RRGGBB" (opaque) or "#AARRGGBB".</summary>
/// <exception cref="FormatException">The text is not a supported hex color.</exception>
public static ArgbColor Parse(string text)
=> TryParse(text, out ArgbColor color)
? color
: throw new FormatException($"'{text}' is not a color; expected #RRGGBB or #AARRGGBB.");
/// <summary>Parses "#RRGGBB" (opaque) or "#AARRGGBB"; false when malformed.</summary>
public static bool TryParse(string? text, out ArgbColor color)
{
color = default;
if (text is null || text.Length is not (7 or 9) || text[0] != '#'
|| !uint.TryParse(text.AsSpan(1), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out uint value))
{
return false;
}
if (text.Length == 7)
{
value |= 0xFF000000;
}
color = new ArgbColor((byte)(value >> 24), (byte)(value >> 16), (byte)(value >> 8), (byte)value);
return true;
}
/// <summary>Parses "#RRGGBB" (opaque) or "#AARRGGBB"; false when malformed.</summary>
public static bool TryParse(string? text, out ArgbColor color)
{
color = default;
if(text is null || text.Length is not (7 or 9) || text[0] != '#'
|| !uint.TryParse(text.AsSpan(1), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out uint value))
{
return false;
}
if(text.Length == 7)
{
value |= 0xFF000000;
}
color = new ArgbColor((byte)(value >> 24), (byte)(value >> 16), (byte)(value >> 8), (byte)value);
return true;
}
}
+11
View File
@@ -0,0 +1,11 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace KofTwentyTwo.AppKit;
/// <summary>A third-party component credited in the About screen.</summary>
/// <param name="Name">Component name, e.g. "Velopack".</param>
/// <param name="License">License name, e.g. "MIT License".</param>
public sealed record Attribution(string Name, string License);
+73 -56
View File
@@ -1,5 +1,11 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Reflection;
namespace KofTwentyTwo.AppKit;
/// <summary>
@@ -10,69 +16,80 @@ namespace KofTwentyTwo.AppKit;
/// </summary>
public static class BuildVersion
{
private const int ShortHashLength = 9;
private const int ShortHashLength = 9;
/// <summary>Describes <paramref name="assembly"/>'s version and commit.</summary>
public static string Describe(Assembly assembly)
{
ArgumentNullException.ThrowIfNull(assembly);
return Format(InformationalVersion(assembly));
}
/// <summary>
/// True when <paramref name="assembly"/> carries a prerelease version (a '-' before
/// any build metadata), e.g. "1.0.0-beta.1" or a local "1.0.1-dev" build. Installs
/// of prerelease builds follow the dev update channel.
/// </summary>
public static bool IsPrerelease(Assembly assembly)
{
ArgumentNullException.ThrowIfNull(assembly);
return IsPrerelease(InformationalVersion(assembly));
}
/// <summary>True when the version part (before any '+metadata') contains a '-'.</summary>
public static bool IsPrerelease(string? informationalVersion)
{
if (informationalVersion is null)
{
return false;
}
/// <summary>Describes <paramref name="assembly"/>'s version and commit.</summary>
public static string Describe(Assembly assembly)
{
ArgumentNullException.ThrowIfNull(assembly);
return Format(InformationalVersion(assembly));
}
int plus = informationalVersion.IndexOf('+', StringComparison.Ordinal);
return (plus >= 0 ? informationalVersion[..plus] : informationalVersion).Contains('-', StringComparison.Ordinal);
}
/// <summary>
/// Formats an informational version: the "+&lt;metadata&gt;" suffix becomes a
/// parenthesized short commit hash.
/// </summary>
public static string Format(string? informationalVersion)
{
if (string.IsNullOrWhiteSpace(informationalVersion))
{
return "unknown";
}
int plus = informationalVersion.IndexOf('+', StringComparison.Ordinal);
if (plus < 0)
{
return informationalVersion;
}
/// <summary>
/// True when <paramref name="assembly"/> carries a prerelease version (a '-' before
/// any build metadata), e.g. "1.0.0-beta.1" or a local "1.0.1-dev" build. Installs
/// of prerelease builds follow the dev update channel.
/// </summary>
public static bool IsPrerelease(Assembly assembly)
{
ArgumentNullException.ThrowIfNull(assembly);
return IsPrerelease(InformationalVersion(assembly));
}
string version = informationalVersion[..plus];
string metadata = informationalVersion[(plus + 1)..];
// Metadata may carry more than the sha (dot-separated); the hash is first.
int dot = metadata.IndexOf('.', StringComparison.Ordinal);
string hash = dot >= 0 ? metadata[..dot] : metadata;
if (hash.Length > ShortHashLength)
{
hash = hash[..ShortHashLength];
}
return hash.Length == 0 ? version : $"{version} ({hash})";
}
private static string? InformationalVersion(Assembly assembly)
=> assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? assembly.GetName().Version?.ToString();
/// <summary>True when the version part (before any '+metadata') contains a '-'.</summary>
public static bool IsPrerelease(string? informationalVersion)
{
if(informationalVersion is null)
{
return false;
}
int plus = informationalVersion.IndexOf('+', StringComparison.Ordinal);
return (plus >= 0 ? informationalVersion[..plus] : informationalVersion).Contains('-', StringComparison.Ordinal);
}
/// <summary>
/// Formats an informational version: the "+&lt;metadata&gt;" suffix becomes a
/// parenthesized short commit hash.
/// </summary>
public static string Format(string? informationalVersion)
{
if(string.IsNullOrWhiteSpace(informationalVersion))
{
return "unknown";
}
int plus = informationalVersion.IndexOf('+', StringComparison.Ordinal);
if(plus < 0)
{
return informationalVersion;
}
string version = informationalVersion[..plus];
string metadata = informationalVersion[(plus + 1)..];
// Metadata may carry more than the sha (dot-separated); the hash is first.
int dot = metadata.IndexOf('.', StringComparison.Ordinal);
string hash = dot >= 0 ? metadata[..dot] : metadata;
if(hash.Length > ShortHashLength)
{
hash = hash[..ShortHashLength];
}
return hash.Length == 0 ? version : $"{version} ({hash})";
}
/// <summary>The informational version the build stamped (tag version plus the SourceLink commit), falling back to the assembly version.</summary>
private static string? InformationalVersion(Assembly assembly)
=> assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? assembly.GetName().Version?.ToString();
}
+43 -34
View File
@@ -1,43 +1,52 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace KofTwentyTwo.AppKit;
/// <summary>Credit and link text for the splash and About screens, shared by every UI package.</summary>
public static class Credits
{
/// <summary>Separator between items on one credit line (en spaces around a middle dot).</summary>
public const string Separator = " · ";
/// <summary>Separator between items on one credit line (en spaces around a middle dot).</summary>
public const string Separator = " · ";
/// <summary>
/// The contact line: author email, the repository owner's profile (e.g.
/// "github.com/KofTwentyTwo"), and the website host, skipping whatever is unset.
/// </summary>
public static string ContactLine(AppInfo app)
{
ArgumentNullException.ThrowIfNull(app);
var parts = new List<string>(3);
if (app.AuthorEmail.Length > 0)
{
parts.Add(app.AuthorEmail);
}
if (OwnerProfile(app.RepositoryUrl) is string owner)
{
parts.Add(owner);
}
if (app.Website is not null)
{
parts.Add(app.Website.Host);
}
return string.Join(Separator, parts);
}
/// <summary>"host/owner" for a repository URL such as https://github.com/owner/repo; null when absent.</summary>
public static string? OwnerProfile(Uri? repositoryUrl)
{
if (repositoryUrl is null)
{
return null;
}
string owner = repositoryUrl.AbsolutePath.Trim('/').Split('/')[0];
return owner.Length == 0 ? repositoryUrl.Host : $"{repositoryUrl.Host}/{owner}";
}
/// <summary>
/// The contact line: author email, the repository owner's profile (e.g.
/// "github.com/KofTwentyTwo"), and the website host, skipping whatever is unset.
/// </summary>
public static string ContactLine(AppInfo app)
{
ArgumentNullException.ThrowIfNull(app);
var parts = new List<string>(3);
if(app.AuthorEmail.Length > 0)
{
parts.Add(app.AuthorEmail);
}
if(OwnerProfile(app.RepositoryUrl) is string owner)
{
parts.Add(owner);
}
if(app.Website is not null)
{
parts.Add(app.Website.Host);
}
return string.Join(Separator, parts);
}
/// <summary>"host/owner" for a repository URL such as https://github.com/owner/repo; null when absent.</summary>
public static string? OwnerProfile(Uri? repositoryUrl)
{
if(repositoryUrl is null)
{
return null;
}
string owner = repositoryUrl.AbsolutePath.Trim('/').Split('/')[0];
return owner.Length == 0 ? repositoryUrl.Host : $"{repositoryUrl.Host}/{owner}";
}
}
@@ -1,3 +1,8 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace KofTwentyTwo.AppKit.Interaction;
/// <summary>
@@ -7,12 +12,14 @@ namespace KofTwentyTwo.AppKit.Interaction;
/// </summary>
public interface IUserPrompter
{
/// <summary>Shows an informational message with a single dismiss button.</summary>
Task ShowMessageAsync(string title, string message);
/// <summary>Shows an informational message with a single dismiss button.</summary>
Task ShowMessageAsync(string title, string message);
/// <summary>
/// Asks a yes/no question. True only when the user chose
/// <paramref name="confirmText"/>; dismissing or a suppressed dialog is false.
/// </summary>
Task<bool> ConfirmAsync(string title, string message, string confirmText, string cancelText);
/// <summary>
/// Asks a yes/no question. True only when the user chose
/// <paramref name="confirmText"/>; dismissing or a suppressed dialog is false.
/// </summary>
Task<bool> ConfirmAsync(string title, string message, string confirmText, string cancelText);
}
@@ -1,19 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>true</IsPackable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Description>UI-framework-free foundation for KofTwentyTwo Windows apps: app identity, per-user data paths, build version display, crash-safe file logging, JSON settings, Windows Credential Manager secret storage, and the user-prompt seam the UI packages implement.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>true</IsPackable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Description>UI-framework-free foundation for KofTwentyTwo Windows apps: app identity, per-user data paths, build version display, crash-safe file logging, JSON settings, Windows Credential Manager secret storage, and the user-prompt seam the UI packages implement.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="KofTwentyTwo.AppKit.Tests" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="KofTwentyTwo.AppKit.Tests" />
</ItemGroup>
</Project>
@@ -1,5 +1,11 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Globalization;
namespace KofTwentyTwo.AppKit.Logging;
/// <summary>
@@ -13,113 +19,133 @@ namespace KofTwentyTwo.AppKit.Logging;
/// </summary>
public sealed class FileActivityLog : IActivityLog
{
/// <summary>Default number of days of log files kept.</summary>
public const int DefaultRetentionDays = 30;
/// <summary>Default number of days of log files kept.</summary>
public const int DefaultRetentionDays = 30;
private const string DateFormat = "yyyy-MM-dd";
private const string DateFormat = "yyyy-MM-dd";
private readonly object _gate = new();
private readonly string _filePrefix;
private readonly TimeProvider _time;
private DateOnly _lastPruned;
private readonly Lock _gate = new();
private readonly string _filePrefix;
private readonly TimeProvider _time;
private DateOnly _lastPruned;
/// <summary>Creates a log that writes under <paramref name="directory"/>.</summary>
/// <param name="directory">Folder for the log files.</param>
/// <param name="filePrefix">File name prefix, normally the app id.</param>
/// <param name="retentionDays">Days of files to keep; 0 or less keeps everything.</param>
/// <param name="time">Clock; the system clock when null.</param>
public FileActivityLog(string directory, string filePrefix, int retentionDays = DefaultRetentionDays, TimeProvider? time = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
ArgumentException.ThrowIfNullOrWhiteSpace(filePrefix);
LogDirectory = directory;
_filePrefix = filePrefix;
RetentionDays = retentionDays;
_time = time ?? TimeProvider.System;
}
/// <summary>Creates the standard log for an app: &lt;data&gt;\logs\&lt;id&gt;-yyyy-MM-dd.log.</summary>
public static FileActivityLog ForApp(AppInfo app, AppPaths paths)
{
ArgumentNullException.ThrowIfNull(app);
ArgumentNullException.ThrowIfNull(paths);
return new FileActivityLog(paths.LogsDirectory, app.Id);
}
/// <inheritdoc/>
public string LogDirectory { get; }
/// <summary>Creates a log that writes under <paramref name="directory"/>.</summary>
/// <param name="directory">Folder for the log files.</param>
/// <param name="filePrefix">File name prefix, normally the app id.</param>
/// <param name="retentionDays">Days of files to keep; 0 or less keeps everything.</param>
/// <param name="time">Clock; the system clock when null.</param>
public FileActivityLog(string directory, string filePrefix, int retentionDays = DefaultRetentionDays, TimeProvider? time = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(directory);
ArgumentException.ThrowIfNullOrWhiteSpace(filePrefix);
LogDirectory = directory;
_filePrefix = filePrefix;
RetentionDays = retentionDays;
_time = time ?? TimeProvider.System;
}
/// <summary>Days of log files kept; 0 or less disables pruning.</summary>
public int RetentionDays { get; }
/// <summary>Today's log file; entries roll over to a new file at local midnight.</summary>
public string CurrentLogFilePath => FilePathFor(Today);
private DateOnly Today => DateOnly.FromDateTime(_time.GetLocalNow().DateTime);
/// <summary>Creates the standard log for an app: &lt;data&gt;\logs\&lt;id&gt;-yyyy-MM-dd.log.</summary>
public static FileActivityLog ForApp(AppInfo app, AppPaths paths)
{
ArgumentNullException.ThrowIfNull(app);
ArgumentNullException.ThrowIfNull(paths);
return new FileActivityLog(paths.LogsDirectory, app.Id);
}
/// <inheritdoc/>
public void Info(string message) => Write("INFO", message, exception: null);
/// <inheritdoc/>
public void Warning(string message) => Write("WARN", message, exception: null);
/// <inheritdoc/>
public void Error(string message, Exception? exception = null) => Write("ERROR", message, exception);
/// <inheritdoc/>
public string LogDirectory { get; }
private string FilePathFor(DateOnly day)
=> Path.Combine(LogDirectory, $"{_filePrefix}-{day.ToString(DateFormat, CultureInfo.InvariantCulture)}.log");
/// <summary>Days of log files kept; 0 or less disables pruning.</summary>
public int RetentionDays { get; }
private void Write(string level, string message, Exception? exception)
{
try
{
lock (_gate)
/// <summary>Today's log file; entries roll over to a new file at local midnight.</summary>
public string CurrentLogFilePath => FilePathFor(Today);
private DateOnly Today => DateOnly.FromDateTime(_time.GetLocalNow().DateTime);
/// <inheritdoc/>
public void Info(string message) => Write("INFO", message, exception: null);
/// <inheritdoc/>
public void Warning(string message) => Write("WARN", message, exception: null);
/// <inheritdoc/>
public void Error(string message, Exception? exception = null) => Write("ERROR", message, exception);
/// <summary>The log file for one day.</summary>
private string FilePathFor(DateOnly day)
=> Path.Combine(LogDirectory, $"{_filePrefix}-{day.ToString(DateFormat, CultureInfo.InvariantCulture)}.log");
/// <summary>Appends one entry under the lock, pruning old files on the first write of a day. Never throws: a logging failure must not take down the operation it describes.</summary>
private void Write(string level, string message, Exception? exception)
{
try
{
lock(_gate)
{
Directory.CreateDirectory(LogDirectory);
PruneOncePerDay();
string timestamp = _time.GetLocalNow().ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
string entry = $"{timestamp} [{level}] {message}";
if(exception is not null)
{
Directory.CreateDirectory(LogDirectory);
PruneOncePerDay();
string timestamp = _time.GetLocalNow().ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
string entry = $"{timestamp} [{level}] {message}";
if (exception is not null)
{
entry += Environment.NewLine + exception;
}
File.AppendAllText(CurrentLogFilePath, entry + Environment.NewLine);
entry += Environment.NewLine + exception;
}
}
catch
{
// A logging failure must never take the operation it describes down.
}
}
/// <summary>Deletes this log's files dated before the retention window. Caller holds the lock.</summary>
private void PruneOncePerDay()
{
DateOnly today = Today;
if (RetentionDays <= 0 || _lastPruned == today)
{
return;
}
_lastPruned = today;
File.AppendAllText(CurrentLogFilePath, entry + Environment.NewLine);
}
}
catch
{
// A logging failure must never take the operation it describes down.
}
}
DateOnly oldestKept = today.AddDays(-(RetentionDays - 1));
foreach (string file in Directory.EnumerateFiles(LogDirectory, _filePrefix + "-*.log"))
{
string stamp = Path.GetFileNameWithoutExtension(file)[(_filePrefix.Length + 1)..];
if (DateOnly.TryParseExact(stamp, DateFormat, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateOnly day)
&& day < oldestKept)
/// <summary>Deletes this log's files dated before the retention window. Caller holds the lock.</summary>
private void PruneOncePerDay()
{
DateOnly today = Today;
if(RetentionDays <= 0 || _lastPruned == today)
{
return;
}
_lastPruned = today;
DateOnly oldestKept = today.AddDays(-(RetentionDays - 1));
foreach(string file in Directory.EnumerateFiles(LogDirectory, _filePrefix + "-*.log"))
{
string stamp = Path.GetFileNameWithoutExtension(file)[(_filePrefix.Length + 1)..];
if(DateOnly.TryParseExact(stamp, DateFormat, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateOnly day)
&& day < oldestKept)
{
try
{
try
{
File.Delete(file);
}
catch
{
// A file another process holds open is retried tomorrow.
}
File.Delete(file);
}
}
}
catch
{
// A file another process holds open is retried tomorrow.
}
}
}
}
}
+15 -32
View File
@@ -1,3 +1,8 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace KofTwentyTwo.AppKit.Logging;
/// <summary>
@@ -7,46 +12,24 @@ namespace KofTwentyTwo.AppKit.Logging;
/// </summary>
public interface IActivityLog
{
/// <summary>Records an informational event.</summary>
void Info(string message);
/// <summary>Records an informational event.</summary>
void Info(string message);
/// <summary>Records something unexpected that did not stop the operation.</summary>
void Warning(string message);
/// <summary>Records a failure, with the exception's full text when one is given.</summary>
void Error(string message, Exception? exception = null);
/// <summary>Folder that holds the log files.</summary>
string LogDirectory { get; }
/// <summary>Records something unexpected that did not stop the operation.</summary>
void Warning(string message);
/// <summary>Full path of the file new entries are currently appended to.</summary>
string CurrentLogFilePath { get; }
}
/// <summary>An <see cref="IActivityLog"/> that discards everything.</summary>
public sealed class NullActivityLog : IActivityLog
{
/// <summary>The shared instance.</summary>
public static NullActivityLog Instance { get; } = new();
/// <inheritdoc/>
public string LogDirectory => "";
/// <summary>Records a failure, with the exception's full text when one is given.</summary>
void Error(string message, Exception? exception = null);
/// <inheritdoc/>
public string CurrentLogFilePath => "";
/// <inheritdoc/>
public void Info(string message)
{
}
/// <inheritdoc/>
public void Warning(string message)
{
}
/// <summary>Folder that holds the log files.</summary>
string LogDirectory { get; }
/// <inheritdoc/>
public void Error(string message, Exception? exception = null)
{
}
/// <summary>Full path of the file new entries are currently appended to.</summary>
string CurrentLogFilePath { get; }
}
@@ -0,0 +1,11 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace KofTwentyTwo.AppKit.Logging;
/// <summary>The last lines of a log file and the file length they were read at.</summary>
/// <param name="Lines">The tail, oldest first.</param>
/// <param name="Length">File length in bytes when read; 0 when the file is absent.</param>
public sealed record LogSnapshot(IReadOnlyList<string> Lines, long Length);
+79 -70
View File
@@ -1,12 +1,12 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Text.RegularExpressions;
namespace KofTwentyTwo.AppKit.Logging;
/// <summary>The last lines of a log file and the file length they were read at.</summary>
/// <param name="Lines">The tail, oldest first.</param>
/// <param name="Length">File length in bytes when read; 0 when the file is absent.</param>
public sealed record LogSnapshot(IReadOnlyList<string> Lines, long Length);
/// <summary>
/// Reads and filters <see cref="FileActivityLog"/> files for the log viewer windows.
/// Never throws: this is diagnostics UI, and an unreadable file becomes a readable
@@ -14,76 +14,85 @@ public sealed record LogSnapshot(IReadOnlyList<string> Lines, long Length);
/// </summary>
public static partial class LogTail
{
/// <summary>Default number of trailing lines a viewer shows.</summary>
public const int DefaultMaxLines = 2000;
/// <summary>Default number of trailing lines a viewer shows.</summary>
public const int DefaultMaxLines = 2000;
/// <summary>
/// The last <paramref name="maxLines"/> lines of <paramref name="path"/>, opened
/// shared so the logger can keep appending while it is read.
/// </summary>
public static LogSnapshot Read(string? path, int maxLines = DefaultMaxLines)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxLines);
try
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
/// <summary>
/// The last <paramref name="maxLines"/> lines of <paramref name="path"/>, opened
/// shared so the logger can keep appending while it is read.
/// </summary>
public static LogSnapshot Read(string? path, int maxLines = DefaultMaxLines)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxLines);
try
{
if(string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return new LogSnapshot([], 0);
}
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
long length = stream.Length;
using var reader = new StreamReader(stream);
var tail = new Queue<string>(maxLines);
while(reader.ReadLine() is string line)
{
if(tail.Count == maxLines)
{
return new LogSnapshot([], 0);
tail.Dequeue();
}
tail.Enqueue(line);
}
return new LogSnapshot([.. tail], length);
}
catch(Exception ex)
{
return new LogSnapshot(["Could not read the log file.", ex.Message], 0);
}
}
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
long length = stream.Length;
using var reader = new StreamReader(stream);
var tail = new Queue<string>(maxLines);
while (reader.ReadLine() is string line)
{
if (tail.Count == maxLines)
{
tail.Dequeue();
}
tail.Enqueue(line);
}
return new LogSnapshot([.. tail], length);
}
catch (Exception ex)
{
return new LogSnapshot(["Could not read the log file.", ex.Message], 0);
}
}
/// <summary>
/// Keeps [ERROR] entries including their continuation lines: exception text spans
/// lines until the next timestamped entry starts.
/// </summary>
public static IReadOnlyList<string> ErrorsOnly(IReadOnlyList<string> lines)
{
ArgumentNullException.ThrowIfNull(lines);
var kept = new List<string>();
bool inError = false;
foreach (string line in lines)
{
if (EntryStart().IsMatch(line))
{
inError = line.Contains("[ERROR]", StringComparison.Ordinal);
}
if (inError)
{
kept.Add(line);
}
}
return kept;
}
/// <summary>
/// Keeps [ERROR] entries including their continuation lines: exception text spans
/// lines until the next timestamped entry starts.
/// </summary>
public static IReadOnlyList<string> ErrorsOnly(IReadOnlyList<string> lines)
{
ArgumentNullException.ThrowIfNull(lines);
var kept = new List<string>();
bool inError = false;
foreach(string line in lines)
{
if(EntryStart().IsMatch(line))
{
inError = line.Contains("[ERROR]", StringComparison.Ordinal);
}
if(inError)
{
kept.Add(line);
}
}
return kept;
}
/// <summary>The text a viewer shows for <paramref name="lines"/>, with friendly empty states.</summary>
public static string ToDisplayText(IReadOnlyList<string> lines, bool errorsOnly)
{
ArgumentNullException.ThrowIfNull(lines);
return lines.Count == 0
? (errorsOnly ? "No errors logged." : "No log entries yet.")
: string.Join(Environment.NewLine, lines);
}
[GeneratedRegex(@"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}")]
private static partial Regex EntryStart();
/// <summary>The text a viewer shows for <paramref name="lines"/>, with friendly empty states.</summary>
public static string ToDisplayText(IReadOnlyList<string> lines, bool errorsOnly)
{
ArgumentNullException.ThrowIfNull(lines);
return lines.Count == 0
? (errorsOnly ? "No errors logged." : "No log entries yet.")
: string.Join(Environment.NewLine, lines);
}
/// <summary>Matches the timestamp that starts every log entry, so continuation lines (exception text) can be told apart.</summary>
[GeneratedRegex(@"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}", RegexOptions.None, matchTimeoutMilliseconds: 1000)]
private static partial Regex EntryStart();
}
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace KofTwentyTwo.AppKit.Logging;
/// <summary>An <see cref="IActivityLog"/> that discards everything.</summary>
public sealed class NullActivityLog : IActivityLog
{
/// <summary>The shared instance.</summary>
public static NullActivityLog Instance { get; } = new();
/// <inheritdoc/>
public string LogDirectory => "";
/// <inheritdoc/>
public string CurrentLogFilePath => "";
/// <inheritdoc/>
public void Info(string message)
{
}
/// <inheritdoc/>
public void Warning(string message)
{
}
/// <inheritdoc/>
public void Error(string message, Exception? exception = null)
{
}
}
@@ -1,8 +1,14 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
namespace KofTwentyTwo.AppKit.Secrets;
/// <summary>
@@ -16,152 +22,182 @@ namespace KofTwentyTwo.AppKit.Secrets;
[SupportedOSPlatform("windows")]
public sealed class CredentialManagerVault : ISecretVault
{
private const uint CredTypeGeneric = 1; // CRED_TYPE_GENERIC
private const uint CredPersistLocalMachine = 2; // CRED_PERSIST_LOCAL_MACHINE: per-user, survives reboot
private const int ErrorNotFound = 1168; // ERROR_NOT_FOUND
private const uint CredTypeGeneric = 1; // CRED_TYPE_GENERIC
private const uint CredPersistLocalMachine = 2; // CRED_PERSIST_LOCAL_MACHINE: per-user, survives reboot
private const int ErrorNotFound = 1168; // ERROR_NOT_FOUND
private readonly string _prefix;
private readonly string _prefix;
/// <summary>Creates a vault whose entries are named "&lt;targetPrefix&gt;:&lt;key&gt;".</summary>
/// <param name="targetPrefix">Normally the app id, so each app's entries are grouped and distinct.</param>
public CredentialManagerVault(string targetPrefix)
: this(targetPrefix, OperatingSystem.IsWindows())
{
}
/// <summary>Test seam: the platform check is a parameter so both arms are coverable.</summary>
internal CredentialManagerVault(string targetPrefix, bool isWindows)
{
ArgumentException.ThrowIfNullOrWhiteSpace(targetPrefix);
if (!isWindows)
{
throw new PlatformNotSupportedException("Secret storage requires the Windows Credential Manager.");
}
_prefix = targetPrefix;
}
/// <summary>The Credential Manager target name used for <paramref name="key"/>.</summary>
public string TargetName(string key)
{
ArgumentException.ThrowIfNullOrEmpty(key);
return $"{_prefix}:{key}";
}
/// <summary>Creates a vault whose entries are named "&lt;targetPrefix&gt;:&lt;key&gt;".</summary>
/// <param name="targetPrefix">Normally the app id, so each app's entries are grouped and distinct.</param>
public CredentialManagerVault(string targetPrefix)
: this(targetPrefix, OperatingSystem.IsWindows())
{
}
/// <inheritdoc/>
public void Store(string key, string secret)
{
ArgumentNullException.ThrowIfNull(secret);
string target = TargetName(key);
byte[] blob = Encoding.Unicode.GetBytes(secret);
var pinned = GCHandle.Alloc(blob, GCHandleType.Pinned);
try
{
var credential = new CredentialW
{
Type = CredTypeGeneric,
TargetName = target,
CredentialBlobSize = (uint)blob.Length,
CredentialBlob = pinned.AddrOfPinnedObject(),
Persist = CredPersistLocalMachine,
UserName = _prefix,
};
if (!CredWriteW(ref credential, 0))
{
int error = Marshal.GetLastWin32Error();
throw new Win32Exception(error, $"CredWriteW failed (error {error}) storing secret '{key}'.");
}
}
finally
{
// The array is pinned, so the GC cannot have copied it elsewhere: clearing
// it removes the one deterministic plaintext copy of the secret from the
// heap (crash dumps, pagefile) before unpinning.
Array.Clear(blob);
pinned.Free();
}
}
/// <summary>Test seam: the platform check is a parameter so both arms are coverable.</summary>
internal CredentialManagerVault(string targetPrefix, bool isWindows)
{
ArgumentException.ThrowIfNullOrWhiteSpace(targetPrefix);
if(!isWindows)
{
throw new PlatformNotSupportedException("Secret storage requires the Windows Credential Manager.");
}
_prefix = targetPrefix;
}
/// <inheritdoc/>
public string? TryRetrieve(string key) => TryRetrieveTarget(TargetName(key), key);
/// <summary>Test seam: an invalid target name makes the non-not-found error arm coverable.</summary>
internal static string? TryRetrieveTarget(string targetName, string key)
{
if (!CredReadW(targetName, CredTypeGeneric, 0, out IntPtr credentialPtr))
{
/// <summary>The Credential Manager target name used for <paramref name="key"/>.</summary>
public string TargetName(string key)
{
ArgumentException.ThrowIfNullOrEmpty(key);
return $"{_prefix}:{key}";
}
/// <inheritdoc/>
public void Store(string key, string secret)
{
ArgumentNullException.ThrowIfNull(secret);
string target = TargetName(key);
byte[] blob = Encoding.Unicode.GetBytes(secret);
var pinned = GCHandle.Alloc(blob, GCHandleType.Pinned);
try
{
var credential = new CredentialW
{
Type = CredTypeGeneric,
TargetName = target,
CredentialBlobSize = (uint)blob.Length,
CredentialBlob = pinned.AddrOfPinnedObject(),
Persist = CredPersistLocalMachine,
UserName = _prefix,
};
if(!CredWriteW(ref credential, 0))
{
int error = Marshal.GetLastWin32Error();
if (error == ErrorNotFound)
{
return null;
}
throw new Win32Exception(error, $"CredReadW failed (error {error}) reading secret '{key}'.");
}
throw new Win32Exception(error, $"CredWriteW failed (error {error}) storing secret '{key}'.");
}
}
finally
{
// The array is pinned, so the GC cannot have copied it elsewhere: clearing
// it removes the one deterministic plaintext copy of the secret from the
// heap (crash dumps, pagefile) before unpinning.
Array.Clear(blob);
pinned.Free();
}
}
try
{
var credential = Marshal.PtrToStructure<CredentialW>(credentialPtr);
return credential.CredentialBlob == IntPtr.Zero || credential.CredentialBlobSize == 0
? ""
: Marshal.PtrToStringUni(credential.CredentialBlob, (int)credential.CredentialBlobSize / 2);
}
finally
{
CredFree(credentialPtr);
}
}
/// <inheritdoc/>
public void Delete(string key) => DeleteTarget(TargetName(key), key);
/// <summary>Test seam: an invalid target name makes the non-not-found error arm coverable.</summary>
internal static void DeleteTarget(string targetName, string key)
{
if (!CredDeleteW(targetName, CredTypeGeneric, 0))
{
int error = Marshal.GetLastWin32Error();
if (error != ErrorNotFound)
{
throw new Win32Exception(error, $"CredDeleteW failed (error {error}) deleting secret '{key}'.");
}
}
}
/// <inheritdoc/>
public string? TryRetrieve(string key) => TryRetrieveTarget(TargetName(key), key);
/// <summary>
/// Managed mirror of the native CREDENTIALW structure. The explicit StructLayout
/// both fixes the field order for marshaling and tells the compiler the unread
/// fields (filled by CredReadW) are intentional.
/// </summary>
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct CredentialW
{
public uint Flags;
public uint Type;
[MarshalAs(UnmanagedType.LPWStr)] public string? TargetName;
[MarshalAs(UnmanagedType.LPWStr)] public string? Comment;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
public uint CredentialBlobSize;
public IntPtr CredentialBlob;
public uint Persist;
public uint AttributeCount;
public IntPtr Attributes;
[MarshalAs(UnmanagedType.LPWStr)] public string? TargetAlias;
[MarshalAs(UnmanagedType.LPWStr)] public string? UserName;
}
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CredWriteW(ref CredentialW credential, uint flags);
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CredReadW(string targetName, uint type, uint flags, out IntPtr credential);
/// <summary>Test seam: an invalid target name makes the non-not-found error arm coverable.</summary>
internal static string? TryRetrieveTarget(string targetName, string key)
{
if(!CredReadW(targetName, CredTypeGeneric, 0, out IntPtr credentialPtr))
{
int error = Marshal.GetLastWin32Error();
if(error == ErrorNotFound)
{
return null;
}
throw new Win32Exception(error, $"CredReadW failed (error {error}) reading secret '{key}'.");
}
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CredDeleteW(string targetName, uint type, uint flags);
try
{
CredentialW credential = Marshal.PtrToStructure<CredentialW>(credentialPtr);
return credential.CredentialBlob == IntPtr.Zero || credential.CredentialBlobSize == 0
? ""
: Marshal.PtrToStringUni(credential.CredentialBlob, (int)credential.CredentialBlobSize / 2);
}
finally
{
CredFree(credentialPtr);
}
}
[DllImport("advapi32.dll", ExactSpelling = true)]
private static extern void CredFree(IntPtr buffer);
/// <inheritdoc/>
public void Delete(string key) => DeleteTarget(TargetName(key), key);
/// <summary>Test seam: an invalid target name makes the non-not-found error arm coverable.</summary>
internal static void DeleteTarget(string targetName, string key)
{
if(!CredDeleteW(targetName, CredTypeGeneric, 0))
{
int error = Marshal.GetLastWin32Error();
if(error != ErrorNotFound)
{
throw new Win32Exception(error, $"CredDeleteW failed (error {error}) deleting secret '{key}'.");
}
}
}
/// <summary>
/// Managed mirror of the native CREDENTIALW structure. The explicit StructLayout
/// both fixes the field order for marshaling and tells the compiler the unread
/// fields (filled by CredReadW) are intentional.
/// </summary>
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct CredentialW
{
public uint Flags;
public uint Type;
[MarshalAs(UnmanagedType.LPWStr)] public string? TargetName;
[MarshalAs(UnmanagedType.LPWStr)] public string? Comment;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
public uint CredentialBlobSize;
public IntPtr CredentialBlob;
public uint Persist;
public uint AttributeCount;
public IntPtr Attributes;
[MarshalAs(UnmanagedType.LPWStr)] public string? TargetAlias;
[MarshalAs(UnmanagedType.LPWStr)] public string? UserName;
}
/// <summary>advapi32 CredWriteW: creates or replaces a credential.</summary>
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CredWriteW(ref CredentialW credential, uint flags);
/// <summary>advapi32 CredReadW: reads a credential into a buffer that must be released with CredFree.</summary>
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CredReadW(string targetName, uint type, uint flags, out IntPtr credential);
/// <summary>advapi32 CredDeleteW: deletes a credential.</summary>
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true, ExactSpelling = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CredDeleteW(string targetName, uint type, uint flags);
/// <summary>advapi32 CredFree: releases a buffer returned by CredReadW.</summary>
[DllImport("advapi32.dll", ExactSpelling = true)]
private static extern void CredFree(IntPtr buffer);
}
+19 -10
View File
@@ -1,3 +1,8 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace KofTwentyTwo.AppKit.Secrets;
/// <summary>
@@ -8,16 +13,20 @@ namespace KofTwentyTwo.AppKit.Secrets;
/// </summary>
public interface ISecretVault
{
/// <summary>
/// Stores the secret under <paramref name="key"/>, overwriting any existing entry.
/// Throws on failure: silently losing a secret would leave the user stuck without
/// any warning.
/// </summary>
void Store(string key, string secret);
/// <summary>
/// Stores the secret under <paramref name="key"/>, overwriting any existing entry.
/// Throws on failure: silently losing a secret would leave the user stuck without
/// any warning.
/// </summary>
void Store(string key, string secret);
/// <summary>The stored secret, or null when the vault has no entry for the key.</summary>
string? TryRetrieve(string key);
/// <summary>Removes the key's secret. Deleting an absent entry is a no-op.</summary>
void Delete(string key);
/// <summary>The stored secret, or null when the vault has no entry for the key.</summary>
string? TryRetrieve(string key);
/// <summary>Removes the key's secret. Deleting an absent entry is a no-op.</summary>
void Delete(string key);
}
@@ -1,5 +1,11 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Collections.Concurrent;
namespace KofTwentyTwo.AppKit.Secrets;
/// <summary>
@@ -8,27 +14,33 @@ namespace KofTwentyTwo.AppKit.Secrets;
/// </summary>
public sealed class InMemorySecretVault : ISecretVault
{
private readonly ConcurrentDictionary<string, string> _secrets = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, string> _secrets = new(StringComparer.Ordinal);
/// <inheritdoc/>
public void Store(string key, string secret)
{
ArgumentException.ThrowIfNullOrEmpty(key);
ArgumentNullException.ThrowIfNull(secret);
_secrets[key] = secret;
}
/// <inheritdoc/>
public string? TryRetrieve(string key)
{
ArgumentException.ThrowIfNullOrEmpty(key);
return _secrets.TryGetValue(key, out string? secret) ? secret : null;
}
/// <inheritdoc/>
public void Delete(string key)
{
ArgumentException.ThrowIfNullOrEmpty(key);
_secrets.TryRemove(key, out _);
}
/// <inheritdoc/>
public void Store(string key, string secret)
{
ArgumentException.ThrowIfNullOrEmpty(key);
ArgumentNullException.ThrowIfNull(secret);
_secrets[key] = secret;
}
/// <inheritdoc/>
public string? TryRetrieve(string key)
{
ArgumentException.ThrowIfNullOrEmpty(key);
return _secrets.TryGetValue(key, out string? secret) ? secret : null;
}
/// <inheritdoc/>
public void Delete(string key)
{
ArgumentException.ThrowIfNullOrEmpty(key);
_secrets.TryRemove(key, out _);
}
}
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace KofTwentyTwo.AppKit.Settings;
/// <summary>The app's color theme.</summary>
public enum AppTheme
{
/// <summary>Follow the Windows setting.</summary>
System,
/// <summary>Always light.</summary>
Light,
/// <summary>Always dark.</summary>
Dark,
}
@@ -0,0 +1,13 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace KofTwentyTwo.AppKit.Settings;
/// <summary>Settings that repair out-of-range or missing values after loading.</summary>
public interface ISanitizable
{
/// <summary>Clamps, defaults, and validates every value in place.</summary>
void Sanitize();
}
@@ -1,15 +1,13 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
namespace KofTwentyTwo.AppKit.Settings;
/// <summary>Settings that repair out-of-range or missing values after loading.</summary>
public interface ISanitizable
{
/// <summary>Clamps, defaults, and validates every value in place.</summary>
void Sanitize();
}
/// <summary>
/// Loads and saves a settings object as indented JSON. Takes a source-generated
/// <see cref="JsonTypeInfo{T}"/> so trimmed Release publishes need no reflection.
@@ -21,81 +19,92 @@ public interface ISanitizable
public sealed class SettingsStore<T>
where T : class, new()
{
private readonly JsonTypeInfo<T> _typeInfo;
private readonly JsonTypeInfo<T> _typeInfo;
/// <summary>Creates a store for the file at <paramref name="path"/>.</summary>
public SettingsStore(string path, JsonTypeInfo<T> typeInfo)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
ArgumentNullException.ThrowIfNull(typeInfo);
Path = path;
_typeInfo = typeInfo;
}
/// <summary>The settings file.</summary>
public string Path { get; }
/// <summary>
/// Loads settings. A missing, corrupt, or unreadable file yields defaults; loaded
/// values are sanitized.
/// </summary>
public T Load()
{
T? loaded = null;
try
{
if (File.Exists(Path))
{
loaded = JsonSerializer.Deserialize(File.ReadAllText(Path), _typeInfo);
}
}
catch
{
// Settings must never prevent startup; fall through to defaults.
}
/// <summary>Creates a store for the file at <paramref name="path"/>.</summary>
public SettingsStore(string path, JsonTypeInfo<T> typeInfo)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
ArgumentNullException.ThrowIfNull(typeInfo);
Path = path;
_typeInfo = typeInfo;
}
loaded ??= new T();
(loaded as ISanitizable)?.Sanitize();
return loaded;
}
/// <summary>
/// Sanitizes and persists <paramref name="settings"/>, creating the folder if
/// needed. Returns false when the write failed (locked file, read-only profile,
/// full disk); losing a preference write must never crash the app.
/// </summary>
public bool Save(T settings)
{
ArgumentNullException.ThrowIfNull(settings);
string temporary = Path + ".tmp";
try
{
(settings as ISanitizable)?.Sanitize();
string? directory = System.IO.Path.GetDirectoryName(Path);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(temporary, JsonSerializer.Serialize(settings, _typeInfo));
File.Move(temporary, Path, overwrite: true);
return true;
}
catch
{
TryDelete(temporary);
return false;
}
}
private static void TryDelete(string path)
{
try
{
File.Delete(path);
}
catch
{
// Best effort: a leftover .tmp is harmless and overwritten next save.
}
}
/// <summary>The settings file.</summary>
public string Path { get; }
/// <summary>
/// Loads settings. A missing, corrupt, or unreadable file yields defaults; loaded
/// values are sanitized.
/// </summary>
public T Load()
{
T? loaded = null;
try
{
if(File.Exists(Path))
{
loaded = JsonSerializer.Deserialize(File.ReadAllText(Path), _typeInfo);
}
}
catch
{
// Settings must never prevent startup; fall through to defaults.
}
loaded ??= new T();
(loaded as ISanitizable)?.Sanitize();
return loaded;
}
/// <summary>
/// Sanitizes and persists <paramref name="settings"/>, creating the folder if
/// needed. Returns false when the write failed (locked file, read-only profile,
/// full disk); losing a preference write must never crash the app.
/// </summary>
public bool Save(T settings)
{
ArgumentNullException.ThrowIfNull(settings);
string temporary = Path + ".tmp";
try
{
(settings as ISanitizable)?.Sanitize();
string? directory = System.IO.Path.GetDirectoryName(Path);
if(!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(temporary, JsonSerializer.Serialize(settings, _typeInfo));
File.Move(temporary, Path, overwrite: true);
return true;
}
catch
{
TryDelete(temporary);
return false;
}
}
/// <summary>Deletes a file if it can; used to clean up the temporary file after a failed save.</summary>
private static void TryDelete(string path)
{
try
{
File.Delete(path);
}
catch
{
// Best effort: a leftover .tmp is harmless and overwritten next save.
}
}
}
@@ -1,20 +1,12 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Text.Json.Serialization;
namespace KofTwentyTwo.AppKit.Settings;
/// <summary>The app's color theme.</summary>
public enum AppTheme
{
/// <summary>Follow the Windows setting.</summary>
System,
/// <summary>Always light.</summary>
Light,
/// <summary>Always dark.</summary>
Dark,
}
/// <summary>
/// Preferences every AppKit app shares: theme, startup splash, and automatic update
/// checks. Apps derive their own settings class from this one and register the derived
@@ -23,48 +15,54 @@ public enum AppTheme
/// </summary>
public class ShellSettings : ISanitizable
{
/// <summary>Shortest splash display time, in milliseconds.</summary>
public const int MinSplashMilliseconds = 500;
/// <summary>Shortest splash display time, in milliseconds.</summary>
public const int MinSplashMilliseconds = 500;
/// <summary>Longest splash display time, in milliseconds.</summary>
public const int MaxSplashMilliseconds = 30_000;
/// <summary>Longest splash display time, in milliseconds.</summary>
public const int MaxSplashMilliseconds = 30_000;
/// <summary>Default splash display time, in milliseconds.</summary>
public const int DefaultSplashMilliseconds = 2_500;
/// <summary>Default splash display time, in milliseconds.</summary>
public const int DefaultSplashMilliseconds = 2_500;
/// <summary>
/// Stored theme name: "System", "Light", or "Dark". Kept as a string so one bad
/// value is repaired by <see cref="Sanitize"/> instead of failing the whole file.
/// </summary>
public string Theme { get; set; } = nameof(AppTheme.System);
/// <summary>
/// Stored theme name: "System", "Light", or "Dark". Kept as a string so one bad
/// value is repaired by <see cref="Sanitize"/> instead of failing the whole file.
/// </summary>
public string Theme { get; set; } = nameof(AppTheme.System);
/// <summary>The theme as an enum; unknown stored values read as <see cref="AppTheme.System"/>.</summary>
[JsonIgnore]
public AppTheme ThemeKind
{
get => Enum.TryParse(Theme, ignoreCase: false, out AppTheme theme) && Enum.IsDefined(theme) ? theme : AppTheme.System;
set => Theme = value.ToString();
}
/// <summary>Whether the branded splash shows at startup.</summary>
public bool ShowSplashScreen { get; set; } = true;
/// <summary>How long the splash stays up, clamped to 500..30000 ms.</summary>
public int SplashMilliseconds { get; set; } = DefaultSplashMilliseconds;
/// <summary>The theme as an enum; unknown stored values read as <see cref="AppTheme.System"/>.</summary>
[JsonIgnore]
public AppTheme ThemeKind
{
get => Enum.TryParse(Theme, ignoreCase: false, out AppTheme theme) && Enum.IsDefined(theme) ? theme : AppTheme.System;
set => Theme = value.ToString();
}
/// <summary>Whether installed builds look for an update shortly after startup.</summary>
public bool CheckForUpdatesOnStartup { get; set; } = true;
/// <inheritdoc/>
public virtual void Sanitize()
{
// The JSON deserializer can assign null despite the non-nullable annotation,
// and numeric strings parse as enum values, so round-trip through the enum.
Theme = ThemeKind.ToString();
// 0 means the field was absent (written by an older version): use the default
// rather than clamping an unset value to the minimum.
SplashMilliseconds = SplashMilliseconds == 0
? DefaultSplashMilliseconds
: Math.Clamp(SplashMilliseconds, MinSplashMilliseconds, MaxSplashMilliseconds);
}
/// <summary>Whether the branded splash shows at startup.</summary>
public bool ShowSplashScreen { get; set; } = true;
/// <summary>How long the splash stays up, clamped to 500..30000 ms.</summary>
public int SplashMilliseconds { get; set; } = DefaultSplashMilliseconds;
/// <summary>Whether installed builds look for an update shortly after startup.</summary>
public bool CheckForUpdatesOnStartup { get; set; } = true;
/// <inheritdoc/>
public virtual void Sanitize()
{
// The JSON deserializer can assign null despite the non-nullable annotation,
// and numeric strings parse as enum values, so round-trip through the enum.
Theme = ThemeKind.ToString();
// 0 means the field was absent (written by an older version): use the default
// rather than clamping an unset value to the minimum.
SplashMilliseconds = SplashMilliseconds == 0
? DefaultSplashMilliseconds
: Math.Clamp(SplashMilliseconds, MinSplashMilliseconds, MaxSplashMilliseconds);
}
}
@@ -0,0 +1,13 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.296, )",
"resolved": "3.0.296",
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
}
}
}
}
+41 -28
View File
@@ -1,35 +1,48 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
namespace KofTwentyTwo.AppKit.Tests;
/// <summary>Tests for the splash and About credit text.</summary>
public class CreditsTests
{
[Fact]
public void ContactLine_JoinsWhatIsSet()
{
var app = new AppInfo
{
Id = "gclo",
DisplayName = "gclo",
AuthorEmail = "james@kof22.com",
RepositoryUrl = new Uri("https://github.com/KofTwentyTwo/gclo"),
Website = new Uri("https://kof22.com/"),
};
Assert.Equal("james@kof22.com · github.com/KofTwentyTwo · kof22.com", Credits.ContactLine(app));
}
/// <summary>ContactLine: joins what is set.</summary>
[Fact]
public void ContactLine_JoinsWhatIsSet()
{
var app = new AppInfo
{
Id = "gclo",
DisplayName = "gclo",
AuthorEmail = "james@kof22.com",
RepositoryUrl = new Uri("https://github.com/KofTwentyTwo/gclo"),
Website = new Uri("https://kof22.com/"),
};
Assert.Equal("james@kof22.com · github.com/KofTwentyTwo · kof22.com", Credits.ContactLine(app));
}
[Fact]
public void ContactLine_EmptyWhenNothingIsSet()
{
Assert.Equal("", Credits.ContactLine(new AppInfo { Id = "x", DisplayName = "X" }));
Assert.Throws<ArgumentNullException>(() => Credits.ContactLine(null!));
}
[Theory]
[InlineData(null, null)]
[InlineData("https://github.com/", "github.com")]
[InlineData("https://github.com/owner", "github.com/owner")]
[InlineData("https://github.com/owner/repo/", "github.com/owner")]
public void OwnerProfile(string? url, string? expected)
{
Assert.Equal(expected, Credits.OwnerProfile(url is null ? null : new Uri(url)));
}
/// <summary>ContactLine: empty when nothing is set.</summary>
[Fact]
public void ContactLine_EmptyWhenNothingIsSet()
{
Assert.Equal("", Credits.ContactLine(new AppInfo { Id = "x", DisplayName = "X" }));
Assert.Throws<ArgumentNullException>(() => Credits.ContactLine(null!));
}
/// <summary>The owner profile is host/owner for any repository URL, or just the host when the path is empty.</summary>
[Theory]
[InlineData(null, null)]
[InlineData("https://github.com/", "github.com")]
[InlineData("https://github.com/owner", "github.com/owner")]
[InlineData("https://github.com/owner/repo/", "github.com/owner")]
public void OwnerProfile(string? url, string? expected)
{
Assert.Equal(expected, Credits.OwnerProfile(url is null ? null : new Uri(url)));
}
}
+130 -70
View File
@@ -1,133 +1,193 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Interaction;
using KofTwentyTwo.AppKit.Logging;
using KofTwentyTwo.AppKit.Updates;
namespace KofTwentyTwo.AppKit.Tests;
/// <summary>A per-test temporary directory, deleted on dispose.</summary>
internal sealed class TempDirectory : IDisposable
{
public TempDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "appkit-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path);
}
/// <summary>Creates a unique folder under the system temp directory.</summary>
public TempDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "appkit-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path);
}
public string Path { get; }
public string File(string name) => System.IO.Path.Combine(Path, name);
public void Dispose()
{
try
{
Directory.Delete(Path, recursive: true);
}
catch (IOException)
{
// A handle a failing test left open; the OS temp cleaner gets it later.
}
}
public string Path { get; }
/// <summary>A path inside the folder.</summary>
public string File(string name) => System.IO.Path.Combine(Path, name);
/// <summary>Deletes the folder; a handle a failing test left open is left for the OS temp cleaner.</summary>
public void Dispose()
{
try
{
Directory.Delete(Path, recursive: true);
}
catch(IOException)
{
// A handle a failing test left open; the OS temp cleaner gets it later.
}
}
}
/// <summary>A settable clock in a fixed UTC+0 zone, so local time equals UTC.</summary>
internal sealed class ManualTimeProvider(DateTimeOffset now) : TimeProvider
{
public DateTimeOffset Now { get; set; } = now;
public DateTimeOffset Now { get; set; } = now;
public override DateTimeOffset GetUtcNow() => Now;
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
/// <summary>The settable current time.</summary>
public override DateTimeOffset GetUtcNow() => Now;
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
/// <summary>An <see cref="IActivityLog"/> that records entries in memory.</summary>
internal sealed class RecordingLog : IActivityLog
{
public List<string> Entries { get; } = [];
public List<string> Entries { get; } = [];
public string LogDirectory => "";
public string LogDirectory => "";
public string CurrentLogFilePath => "";
public string CurrentLogFilePath => "";
public void Info(string message) => Entries.Add("INFO " + message);
public void Warning(string message) => Entries.Add("WARN " + message);
public void Error(string message, Exception? exception = null) => Entries.Add("ERROR " + message);
/// <summary>Records an INFO entry.</summary>
public void Info(string message) => Entries.Add("INFO " + message);
/// <summary>Records a WARN entry.</summary>
public void Warning(string message) => Entries.Add("WARN " + message);
/// <summary>Records an ERROR entry (the exception is not needed by the tests).</summary>
public void Error(string message, Exception? exception = null) => Entries.Add("ERROR " + message);
}
/// <summary>An <see cref="IUserPrompter"/> that records prompts and answers confirmations from a script.</summary>
internal sealed class RecordingPrompter(bool confirm = false) : IUserPrompter
{
public List<(string Title, string Message)> Messages { get; } = [];
public List<(string Title, string Message)> Messages { get; } = [];
public List<(string Title, string Message, string ConfirmText, string CancelText)> Confirmations { get; } = [];
public List<(string Title, string Message, string ConfirmText, string CancelText)> Confirmations { get; } = [];
public Task ShowMessageAsync(string title, string message)
{
Messages.Add((title, message));
return Task.CompletedTask;
}
public Task<bool> ConfirmAsync(string title, string message, string confirmText, string cancelText)
{
Confirmations.Add((title, message, confirmText, cancelText));
return Task.FromResult(confirm);
}
/// <summary>Records the message.</summary>
public Task ShowMessageAsync(string title, string message)
{
Messages.Add((title, message));
return Task.CompletedTask;
}
/// <summary>Records the question and answers with the scripted choice.</summary>
public Task<bool> ConfirmAsync(string title, string message, string confirmText, string cancelText)
{
Confirmations.Add((title, message, confirmText, cancelText));
return Task.FromResult(confirm);
}
}
/// <summary>A scriptable <see cref="IUpdateService"/>.</summary>
internal sealed class FakeUpdateService : IUpdateService
{
public bool IsSupported { get; set; } = true;
public bool IsSupported { get; set; } = true;
public string? CurrentVersion { get; set; } = "1.0.0";
public string? CurrentVersion { get; set; } = "1.0.0";
public UpdateCheckResult NextCheck { get; set; } = UpdateCheckResult.UpToDate;
public UpdateCheckResult NextCheck { get; set; } = UpdateCheckResult.UpToDate;
public string? ApplyError { get; set; }
public string? ApplyError { get; set; }
public int ApplyCalls { get; private set; }
public int ApplyCalls { get; private set; }
public Task<UpdateCheckResult> CheckAsync(CancellationToken cancellationToken = default) => Task.FromResult(NextCheck);
public Task<string?> DownloadAndApplyAsync(IProgress<int>? progress = null, CancellationToken cancellationToken = default)
{
ApplyCalls++;
return Task.FromResult(ApplyError);
}
/// <summary>Returns the scripted check result.</summary>
public Task<UpdateCheckResult> CheckAsync(CancellationToken cancellationToken = default) => Task.FromResult(NextCheck);
/// <summary>Counts the call and returns the scripted error, or null for success.</summary>
public Task<string?> DownloadAndApplyAsync(IProgress<int>? progress = null, CancellationToken cancellationToken = default)
{
ApplyCalls++;
return Task.FromResult(ApplyError);
}
}
/// <summary>A scriptable Velopack stand-in.</summary>
internal sealed class FakeBackend : IUpdateBackend
{
public bool IsInstalled { get; set; } = true;
public bool IsInstalled { get; set; } = true;
public string? CurrentVersion { get; set; } = "1.0.0";
public string? CurrentVersion { get; set; } = "1.0.0";
public string? Available { get; set; }
public string? Available { get; set; }
public Exception? CheckFailure { get; set; }
public Exception? CheckFailure { get; set; }
public Exception? DownloadFailure { get; set; }
public Exception? DownloadFailure { get; set; }
public int Downloads { get; private set; }
public int Downloads { get; private set; }
public int Applies { get; private set; }
public int Applies { get; private set; }
public Task<string?> CheckAsync()
=> CheckFailure is null ? Task.FromResult(Available) : Task.FromException<string?>(CheckFailure);
public Task DownloadAsync(Action<int>? progress, CancellationToken cancellationToken)
{
if (DownloadFailure is not null)
{
return Task.FromException(DownloadFailure);
}
Downloads++;
progress?.Invoke(100);
return Task.CompletedTask;
}
public void ApplyAndRestart() => Applies++;
/// <summary>Returns the scripted available version, or fails with the scripted exception.</summary>
public Task<string?> CheckAsync()
=> CheckFailure is null ? Task.FromResult(Available) : Task.FromException<string?>(CheckFailure);
/// <summary>Counts the download and reports 100% progress, or fails with the scripted exception.</summary>
public Task DownloadAsync(Action<int>? progress, CancellationToken cancellationToken)
{
if(DownloadFailure is not null)
{
return Task.FromException(DownloadFailure);
}
Downloads++;
progress?.Invoke(100);
return Task.CompletedTask;
}
/// <summary>Counts the apply; the real backend would exit the process here.</summary>
public void ApplyAndRestart() => Applies++;
}
+275 -199
View File
@@ -1,238 +1,314 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.Reflection;
using System.Reflection.Emit;
namespace KofTwentyTwo.AppKit.Tests;
/// <summary>Tests for app identity: id validation, defaults, and derived names.</summary>
public class AppInfoTests
{
[Theory]
[InlineData("gclo")]
[InlineData("my-app-2")]
[InlineData("9lives")]
public void Id_AcceptsValidIds(string id)
{
var app = new AppInfo { Id = id, DisplayName = "X" };
Assert.Equal(id, app.Id);
}
/// <summary>Id: accepts valid ids.</summary>
[Theory]
[InlineData("gclo")]
[InlineData("my-app-2")]
[InlineData("9lives")]
public void Id_AcceptsValidIds(string id)
{
var app = new AppInfo { Id = id, DisplayName = "X" };
Assert.Equal(id, app.Id);
}
[Theory]
[InlineData("")]
[InlineData("Gclo")]
[InlineData("-app")]
[InlineData("my app")]
[InlineData("my_app")]
[InlineData(null)]
public void Id_RejectsInvalidIds(string? id)
{
var ex = Assert.Throws<ArgumentException>(() => new AppInfo { Id = id!, DisplayName = "X" });
Assert.Equal("Id", ex.ParamName);
}
[Fact]
public void DataDirectoryVariable_IsUpperSnakeCaseOfId()
{
Assert.Equal("MY_APP_DATA_DIR", new AppInfo { Id = "my-app", DisplayName = "X" }.DataDirectoryVariable);
}
[Fact]
public void Defaults_AreEmptyAndBrandIsSet()
{
var app = new AppInfo { Id = "x", DisplayName = "X" };
Assert.Equal("", app.Tagline);
Assert.Equal("", app.Description);
Assert.Null(app.RepositoryUrl);
Assert.Equal("", app.Author);
Assert.Equal("", app.AuthorEmail);
Assert.Null(app.Website);
Assert.Equal("MIT License", app.License);
Assert.Equal("", app.Copyright);
Assert.Empty(app.Attributions);
Assert.Equal("#3B4CCA", app.Brand.GradientStart);
Assert.Equal("#2A2F8F", app.Brand.GradientEnd);
Assert.Equal("Assets/Brand/app-icon.svg", app.Brand.IconSvgPath);
Assert.Equal("Assets/Brand/app.ico", app.Brand.IconIcoPath);
}
/// <summary>Id: rejects invalid ids.</summary>
[Theory]
[InlineData("")]
[InlineData("Gclo")]
[InlineData("-app")]
[InlineData("my app")]
[InlineData("my_app")]
[InlineData(null)]
public void Id_RejectsInvalidIds(string? id)
{
ArgumentException ex = Assert.Throws<ArgumentException>(() => new AppInfo { Id = id!, DisplayName = "X" });
Assert.Equal("value", ex.ParamName);
}
[Fact]
public void AllProperties_RoundTrip()
{
var app = new AppInfo
{
Id = "x",
DisplayName = "X",
Tagline = "t",
Description = "d",
RepositoryUrl = new Uri("https://github.com/a/b"),
Author = "a",
AuthorEmail = "a@b.c",
Website = new Uri("https://b.c"),
License = "Apache-2.0",
Copyright = "(c)",
Brand = new AppBrand { GradientStart = "#000000", GradientEnd = "#FFFFFF", IconSvgPath = "a.svg", IconIcoPath = "a.ico" },
Attributions = [new Attribution("Velopack", "MIT License")],
};
Assert.Equal("Velopack", app.Attributions[0].Name);
Assert.Equal("MIT License", app.Attributions[0].License);
Assert.Equal("a.ico", app.Brand.IconIcoPath);
Assert.Equal(app, app with { });
}
/// <summary>DataDirectoryVariable: is upper snake case of id.</summary>
[Fact]
public void DataDirectoryVariable_IsUpperSnakeCaseOfId()
{
Assert.Equal("MY_APP_DATA_DIR", new AppInfo { Id = "my-app", DisplayName = "X" }.DataDirectoryVariable);
}
/// <summary>Defaults: are empty and brand is set.</summary>
[Fact]
public void Defaults_AreEmptyAndBrandIsSet()
{
var app = new AppInfo { Id = "x", DisplayName = "X" };
Assert.Equal("", app.Tagline);
Assert.Equal("", app.Description);
Assert.Null(app.RepositoryUrl);
Assert.Equal("", app.Author);
Assert.Equal("", app.AuthorEmail);
Assert.Null(app.Website);
Assert.Equal("MIT License", app.License);
Assert.Equal("", app.Copyright);
Assert.Empty(app.Attributions);
Assert.Equal("#3B4CCA", app.Brand.GradientStart);
Assert.Equal("#2A2F8F", app.Brand.GradientEnd);
Assert.Equal("Assets/Brand/app-icon.svg", app.Brand.IconSvgPath);
Assert.Equal("Assets/Brand/app.ico", app.Brand.IconIcoPath);
}
/// <summary>AllProperties: round trip.</summary>
[Fact]
public void AllProperties_RoundTrip()
{
var app = new AppInfo
{
Id = "x",
DisplayName = "X",
Tagline = "t",
Description = "d",
RepositoryUrl = new Uri("https://github.com/a/b"),
Author = "a",
AuthorEmail = "a@b.c",
Website = new Uri("https://b.c"),
License = "Apache-2.0",
Copyright = "(c)",
Brand = new AppBrand { GradientStart = "#000000", GradientEnd = "#FFFFFF", IconSvgPath = "a.svg", IconIcoPath = "a.ico" },
Attributions = [new Attribution("Velopack", "MIT License")],
};
Assert.Equal("Velopack", app.Attributions[0].Name);
Assert.Equal("MIT License", app.Attributions[0].License);
Assert.Equal("a.ico", app.Brand.IconIcoPath);
Assert.Equal(app, app with { });
}
}
/// <summary>Tests for parsing brand colors.</summary>
public class ArgbColorTests
{
[Fact]
public void Parse_SixDigits_IsOpaque()
{
Assert.Equal(new ArgbColor(0xFF, 0x3B, 0x4C, 0xCA), ArgbColor.Parse("#3B4CCA"));
}
/// <summary>Parse: six digits is opaque.</summary>
[Fact]
public void Parse_SixDigits_IsOpaque()
{
Assert.Equal(new ArgbColor(0xFF, 0x3B, 0x4C, 0xCA), ArgbColor.Parse("#3B4CCA"));
}
[Fact]
public void Parse_EightDigits_KeepsAlpha()
{
Assert.Equal(new ArgbColor(0x33, 0xFF, 0xFF, 0xFF), ArgbColor.Parse("#33ffffff"));
}
[Fact]
public void Components_AreExposed()
{
ArgbColor color = ArgbColor.Parse("#01020304");
Assert.Equal((1, 2, 3, 4), (color.A, color.R, color.G, color.B));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("3B4CCA1")]
[InlineData("#3B4CC")]
[InlineData("#GGGGGG")]
public void TryParse_RejectsMalformed(string? text)
{
Assert.False(ArgbColor.TryParse(text, out ArgbColor color));
Assert.Equal(default, color);
}
/// <summary>Parse: eight digits keeps alpha.</summary>
[Fact]
public void Parse_EightDigits_KeepsAlpha()
{
Assert.Equal(new ArgbColor(0x33, 0xFF, 0xFF, 0xFF), ArgbColor.Parse("#33ffffff"));
}
[Fact]
public void Parse_Malformed_Throws()
{
Assert.Throws<FormatException>(() => ArgbColor.Parse("red"));
}
/// <summary>Components: are exposed.</summary>
[Fact]
public void Components_AreExposed()
{
var color = ArgbColor.Parse("#01020304");
Assert.Equal((1, 2, 3, 4), (color.A, color.R, color.G, color.B));
}
/// <summary>TryParse: rejects malformed.</summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("3B4CCA1")]
[InlineData("#3B4CC")]
[InlineData("#GGGGGG")]
public void TryParse_RejectsMalformed(string? text)
{
Assert.False(ArgbColor.TryParse(text, out ArgbColor color));
Assert.Equal(default, color);
}
/// <summary>Parse: malformed throws.</summary>
[Fact]
public void Parse_Malformed_Throws()
{
Assert.Throws<FormatException>(() => ArgbColor.Parse("red"));
}
}
/// <summary>Tests for the per-user data paths and the data-directory override.</summary>
public class AppPathsTests
{
private static readonly AppInfo App = new() { Id = "my-app", DisplayName = "My App" };
private static readonly AppInfo s_app = new() { Id = "my-app", DisplayName = "My App" };
[Fact]
public void DataRoot_DefaultsToLocalAppData()
{
var paths = new AppPaths(App, _ => null);
string expected = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "my-app");
Assert.Equal(expected, paths.DataRoot);
}
[Fact]
public void DataRoot_EmptyOverride_UsesDefault()
{
var paths = new AppPaths(App, _ => "");
Assert.EndsWith("my-app", paths.DataRoot, StringComparison.Ordinal);
}
[Fact]
public void DataRoot_HonorsOverrideVariable()
{
string? asked = null;
var paths = new AppPaths(App, name =>
{
asked = name;
return @"D:\portable";
});
Assert.Equal(@"D:\portable", paths.DataRoot);
Assert.Equal("MY_APP_DATA_DIR", asked);
Assert.Equal(@"D:\portable\logs", paths.LogsDirectory);
Assert.Equal(@"D:\portable\settings.json", paths.SettingsFile);
Assert.Equal(@"D:\portable\accounts.json", paths.GetFile("accounts.json"));
}
/// <summary>DataRoot: defaults to local app data.</summary>
[Fact]
public void DataRoot_DefaultsToLocalAppData()
{
var paths = new AppPaths(s_app, _ => null);
string expected = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "my-app");
Assert.Equal(expected, paths.DataRoot);
}
[Fact]
public void PublicConstructor_ReadsTheRealEnvironment()
{
var unique = new AppInfo { Id = "appkit-test-" + Guid.NewGuid().ToString("N"), DisplayName = "X" };
Assert.EndsWith(unique.Id, new AppPaths(unique).DataRoot, StringComparison.Ordinal);
}
[Fact]
public void Constructor_NullApp_Throws()
{
Assert.Throws<ArgumentNullException>(() => new AppPaths(null!));
}
/// <summary>DataRoot: empty override uses default.</summary>
[Fact]
public void DataRoot_EmptyOverride_UsesDefault()
{
var paths = new AppPaths(s_app, _ => "");
Assert.EndsWith("my-app", paths.DataRoot, StringComparison.Ordinal);
}
/// <summary>DataRoot: honors override variable.</summary>
[Fact]
public void DataRoot_HonorsOverrideVariable()
{
string? asked = null;
var paths = new AppPaths(s_app, name =>
{
asked = name;
return @"D:\portable";
});
Assert.Equal(@"D:\portable", paths.DataRoot);
Assert.Equal("MY_APP_DATA_DIR", asked);
Assert.Equal(@"D:\portable\logs", paths.LogsDirectory);
Assert.Equal(@"D:\portable\settings.json", paths.SettingsFile);
Assert.Equal(@"D:\portable\accounts.json", paths.GetFile("accounts.json"));
}
/// <summary>PublicConstructor: reads the real environment.</summary>
[Fact]
public void PublicConstructor_ReadsTheRealEnvironment()
{
var unique = new AppInfo { Id = "appkit-test-" + Guid.NewGuid().ToString("N"), DisplayName = "X" };
Assert.EndsWith(unique.Id, new AppPaths(unique).DataRoot, StringComparison.Ordinal);
}
/// <summary>Constructor: null app throws.</summary>
[Fact]
public void Constructor_NullApp_Throws()
{
Assert.Throws<ArgumentNullException>(() => new AppPaths(null!));
}
}
/// <summary>Tests for the build identity text and prerelease detection.</summary>
public class BuildVersionTests
{
[Theory]
[InlineData(null, "unknown")]
[InlineData(" ", "unknown")]
[InlineData("1.2.3", "1.2.3")]
[InlineData("1.2.3-beta.1+abc", "1.2.3-beta.1 (abc)")]
[InlineData("1.2.3+0123456789abcdef", "1.2.3 (012345678)")]
[InlineData("1.2.3+abc.more", "1.2.3 (abc)")]
[InlineData("1.2.3+", "1.2.3")]
public void Format_ShortensCommitMetadata(string? input, string expected)
{
Assert.Equal(expected, BuildVersion.Format(input));
}
/// <summary>Format: shortens commit metadata.</summary>
[Theory]
[InlineData(null, "unknown")]
[InlineData(" ", "unknown")]
[InlineData("1.2.3", "1.2.3")]
[InlineData("1.2.3-beta.1+abc", "1.2.3-beta.1 (abc)")]
[InlineData("1.2.3+0123456789abcdef", "1.2.3 (012345678)")]
[InlineData("1.2.3+abc.more", "1.2.3 (abc)")]
[InlineData("1.2.3+", "1.2.3")]
public void Format_ShortensCommitMetadata(string? input, string expected)
{
Assert.Equal(expected, BuildVersion.Format(input));
}
[Theory]
[InlineData(null, false)]
[InlineData("1.2.3", false)]
[InlineData("1.2.3+sha-with-dash", false)]
[InlineData("1.2.3-dev", true)]
[InlineData("1.2.3-beta.1+abc", true)]
public void IsPrerelease_LooksOnlyAtTheVersionPart(string? input, bool expected)
{
Assert.Equal(expected, BuildVersion.IsPrerelease(input));
}
[Fact]
public void Describe_ReadsInformationalVersion()
{
Assembly assembly = Emit(informationalVersion: "4.5.6-rc.1+deadbeef", version: null);
Assert.Equal("4.5.6-rc.1 (deadbeef)", BuildVersion.Describe(assembly));
Assert.True(BuildVersion.IsPrerelease(assembly));
}
[Fact]
public void Describe_FallsBackToAssemblyVersion()
{
Assembly assembly = Emit(informationalVersion: null, version: new Version(1, 2, 3, 4));
Assert.Equal("1.2.3.4", BuildVersion.Describe(assembly));
Assert.False(BuildVersion.IsPrerelease(assembly));
}
/// <summary>IsPrerelease: looks only at the version part.</summary>
[Theory]
[InlineData(null, false)]
[InlineData("1.2.3", false)]
[InlineData("1.2.3+sha-with-dash", false)]
[InlineData("1.2.3-dev", true)]
[InlineData("1.2.3-beta.1+abc", true)]
public void IsPrerelease_LooksOnlyAtTheVersionPart(string? input, bool expected)
{
Assert.Equal(expected, BuildVersion.IsPrerelease(input));
}
[Fact]
public void Describe_NoVersionAttributes_IsZeroVersion()
{
Assembly assembly = Emit(informationalVersion: null, version: null);
Assert.Equal("0.0.0.0", BuildVersion.Describe(assembly));
Assert.False(BuildVersion.IsPrerelease(assembly));
}
[Fact]
public void NullAssembly_Throws()
{
Assert.Throws<ArgumentNullException>(() => BuildVersion.Describe(null!));
Assert.Throws<ArgumentNullException>(() => BuildVersion.IsPrerelease((Assembly)null!));
}
private static Assembly Emit(string? informationalVersion, Version? version)
{
var name = new AssemblyName("Emitted" + Guid.NewGuid().ToString("N")) { Version = version };
AssemblyBuilder builder = AssemblyBuilder.DefineDynamicAssembly(name, AssemblyBuilderAccess.Run);
if (informationalVersion is not null)
{
ConstructorInfo ctor = typeof(AssemblyInformationalVersionAttribute).GetConstructor([typeof(string)])!;
builder.SetCustomAttribute(new CustomAttributeBuilder(ctor, [informationalVersion]));
}
return builder;
}
/// <summary>Describe: reads informational version.</summary>
[Fact]
public void Describe_ReadsInformationalVersion()
{
Assembly assembly = Emit(informationalVersion: "4.5.6-rc.1+deadbeef", version: null);
Assert.Equal("4.5.6-rc.1 (deadbeef)", BuildVersion.Describe(assembly));
Assert.True(BuildVersion.IsPrerelease(assembly));
}
/// <summary>Describe: falls back to assembly version.</summary>
[Fact]
public void Describe_FallsBackToAssemblyVersion()
{
Assembly assembly = Emit(informationalVersion: null, version: new Version(1, 2, 3, 4));
Assert.Equal("1.2.3.4", BuildVersion.Describe(assembly));
Assert.False(BuildVersion.IsPrerelease(assembly));
}
/// <summary>Describe: no version attributes is zero version.</summary>
[Fact]
public void Describe_NoVersionAttributes_IsZeroVersion()
{
Assembly assembly = Emit(informationalVersion: null, version: null);
Assert.Equal("0.0.0.0", BuildVersion.Describe(assembly));
Assert.False(BuildVersion.IsPrerelease(assembly));
}
/// <summary>NullAssembly: throws.</summary>
[Fact]
public void NullAssembly_Throws()
{
Assert.Throws<ArgumentNullException>(() => BuildVersion.Describe(null!));
Assert.Throws<ArgumentNullException>(() => BuildVersion.IsPrerelease((Assembly)null!));
}
/// <summary>Emits an in-memory assembly with the given informational and assembly versions, so every fallback branch can be tested.</summary>
private static AssemblyBuilder Emit(string? informationalVersion, Version? version)
{
var name = new AssemblyName("Emitted" + Guid.NewGuid().ToString("N")) { Version = version };
var builder = AssemblyBuilder.DefineDynamicAssembly(name, AssemblyBuilderAccess.Run);
if(informationalVersion is not null)
{
ConstructorInfo ctor = typeof(AssemblyInformationalVersionAttribute).GetConstructor([typeof(string)])!;
builder.SetCustomAttribute(new CustomAttributeBuilder(ctor, [informationalVersion]));
}
return builder;
}
}
@@ -1,24 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Windows TFM: the Credential Manager vault tests call the real advapi32 store. -->
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
</PropertyGroup>
<!-- Windows TFM: the Credential Manager vault tests call the real advapi32 store. -->
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\KofTwentyTwo.AppKit\KofTwentyTwo.AppKit.csproj" />
<ProjectReference Include="..\..\src\KofTwentyTwo.AppKit.Updates\KofTwentyTwo.AppKit.Updates.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\KofTwentyTwo.AppKit\KofTwentyTwo.AppKit.csproj" />
<ProjectReference Include="..\..\src\KofTwentyTwo.AppKit.Updates\KofTwentyTwo.AppKit.Updates.csproj" />
</ItemGroup>
</Project>
+235 -171
View File
@@ -1,210 +1,274 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Logging;
namespace KofTwentyTwo.AppKit.Tests;
/// <summary>Tests for the daily file log: entry format, rollover, retention, and failure handling.</summary>
public sealed class FileActivityLogTests : IDisposable
{
private readonly TempDirectory _temp = new();
private readonly ManualTimeProvider _clock = new(new DateTimeOffset(2026, 3, 15, 9, 30, 0, TimeSpan.Zero));
private readonly TempDirectory _temp = new();
private readonly ManualTimeProvider _clock = new(new DateTimeOffset(2026, 3, 15, 9, 30, 0, TimeSpan.Zero));
public void Dispose() => _temp.Dispose();
[Fact]
public void Writes_TimestampedLevels_ToTodaysFile()
{
var log = new FileActivityLog(_temp.Path, "app", time: _clock);
log.Info("hello");
log.Warning("careful");
log.Error("boom", new InvalidOperationException("inner detail"));
log.Error("plain");
Assert.Equal(_temp.File("app-2026-03-15.log"), log.CurrentLogFilePath);
string text = File.ReadAllText(log.CurrentLogFilePath);
Assert.Contains("2026-03-15 09:30:00.000 [INFO] hello", text, StringComparison.Ordinal);
Assert.Contains("[WARN] careful", text, StringComparison.Ordinal);
Assert.Contains("[ERROR] boom", text, StringComparison.Ordinal);
Assert.Contains("inner detail", text, StringComparison.Ordinal);
Assert.Contains("[ERROR] plain", text, StringComparison.Ordinal);
}
/// <summary>Deletes the test&apos;s temporary folder.</summary>
public void Dispose() => _temp.Dispose();
[Fact]
public void RollsOver_AtMidnight()
{
var log = new FileActivityLog(_temp.Path, "app", time: _clock);
log.Info("day one");
_clock.Now = _clock.Now.AddDays(1);
log.Info("day two");
Assert.True(File.Exists(_temp.File("app-2026-03-15.log")));
Assert.Contains("day two", File.ReadAllText(_temp.File("app-2026-03-16.log")), StringComparison.Ordinal);
}
[Fact]
public void Prunes_FilesOutsideRetention_OncePerDay()
{
File.WriteAllText(_temp.File("app-2026-03-13.log"), "kept: inside 3 days");
File.WriteAllText(_temp.File("app-2026-03-12.log"), "pruned");
File.WriteAllText(_temp.File("app-notadate.log"), "ignored: not a dated name");
File.WriteAllText(_temp.File("other-2020-01-01.log"), "ignored: another prefix");
var log = new FileActivityLog(_temp.Path, "app", retentionDays: 3, time: _clock);
log.Info("first");
Assert.False(File.Exists(_temp.File("app-2026-03-12.log")));
Assert.True(File.Exists(_temp.File("app-2026-03-13.log")));
Assert.True(File.Exists(_temp.File("app-notadate.log")));
Assert.True(File.Exists(_temp.File("other-2020-01-01.log")));
/// <summary>Writes: timestamped levels to todays file.</summary>
[Fact]
public void Writes_TimestampedLevels_ToTodaysFile()
{
var log = new FileActivityLog(_temp.Path, "app", time: _clock);
log.Info("hello");
log.Warning("careful");
log.Error("boom", new InvalidOperationException("inner detail"));
log.Error("plain");
// Already pruned today: a newly stale file survives until tomorrow's first write.
File.WriteAllText(_temp.File("app-2026-01-01.log"), "stale");
log.Info("second");
Assert.True(File.Exists(_temp.File("app-2026-01-01.log")));
Assert.Equal(_temp.File("app-2026-03-15.log"), log.CurrentLogFilePath);
string text = File.ReadAllText(log.CurrentLogFilePath);
Assert.Contains("2026-03-15 09:30:00.000 [INFO] hello", text, StringComparison.Ordinal);
Assert.Contains("[WARN] careful", text, StringComparison.Ordinal);
Assert.Contains("[ERROR] boom", text, StringComparison.Ordinal);
Assert.Contains("inner detail", text, StringComparison.Ordinal);
Assert.Contains("[ERROR] plain", text, StringComparison.Ordinal);
}
_clock.Now = _clock.Now.AddDays(1);
log.Info("next day");
Assert.False(File.Exists(_temp.File("app-2026-01-01.log")));
Assert.False(File.Exists(_temp.File("app-2026-03-13.log")));
}
[Fact]
public void Prune_SkipsFilesItCannotDelete()
{
string locked = _temp.File("app-2020-01-01.log");
File.WriteAllText(locked, "held open");
using (new FileStream(locked, FileMode.Open, FileAccess.Read, FileShare.None))
{
var log = new FileActivityLog(_temp.Path, "app", retentionDays: 1, time: _clock);
log.Info("still logs");
Assert.True(File.Exists(log.CurrentLogFilePath));
}
Assert.True(File.Exists(locked));
}
[Fact]
public void ZeroRetention_KeepsEverything()
{
File.WriteAllText(_temp.File("app-2000-01-01.log"), "ancient");
var log = new FileActivityLog(_temp.Path, "app", retentionDays: 0, time: _clock);
log.Info("x");
Assert.Equal(0, log.RetentionDays);
Assert.True(File.Exists(_temp.File("app-2000-01-01.log")));
}
/// <summary>RollsOver: at midnight.</summary>
[Fact]
public void RollsOver_AtMidnight()
{
var log = new FileActivityLog(_temp.Path, "app", time: _clock);
log.Info("day one");
_clock.Now = _clock.Now.AddDays(1);
log.Info("day two");
Assert.True(File.Exists(_temp.File("app-2026-03-15.log")));
Assert.Contains("day two", File.ReadAllText(_temp.File("app-2026-03-16.log")), StringComparison.Ordinal);
}
[Fact]
public void WriteFailures_AreSwallowed()
{
string notADirectory = _temp.File("file");
File.WriteAllText(notADirectory, "");
var log = new FileActivityLog(notADirectory, "app", time: _clock);
log.Info("goes nowhere");
log.Error("also nowhere", new InvalidOperationException());
Assert.Equal(notADirectory, log.LogDirectory);
}
[Fact]
public void ForApp_UsesTheAppsLogsFolderAndId()
{
var app = new AppInfo { Id = "my-app", DisplayName = "X" };
var paths = new AppPaths(app, _ => _temp.Path);
FileActivityLog log = FileActivityLog.ForApp(app, paths);
Assert.Equal(paths.LogsDirectory, log.LogDirectory);
Assert.Equal(FileActivityLog.DefaultRetentionDays, log.RetentionDays);
Assert.StartsWith("my-app-", Path.GetFileName(log.CurrentLogFilePath), StringComparison.Ordinal);
Assert.Throws<ArgumentNullException>(() => FileActivityLog.ForApp(null!, paths));
Assert.Throws<ArgumentNullException>(() => FileActivityLog.ForApp(app, null!));
}
[Fact]
public void Constructor_ValidatesArguments()
{
Assert.Throws<ArgumentException>(() => new FileActivityLog(" ", "app"));
Assert.Throws<ArgumentException>(() => new FileActivityLog(_temp.Path, ""));
Assert.NotNull(new FileActivityLog(_temp.Path, "app").CurrentLogFilePath);
}
/// <summary>Prunes: files outside retention once per day.</summary>
[Fact]
public void Prunes_FilesOutsideRetention_OncePerDay()
{
File.WriteAllText(_temp.File("app-2026-03-13.log"), "kept: inside 3 days");
File.WriteAllText(_temp.File("app-2026-03-12.log"), "pruned");
File.WriteAllText(_temp.File("app-notadate.log"), "ignored: not a dated name");
File.WriteAllText(_temp.File("other-2020-01-01.log"), "ignored: another prefix");
[Fact]
public void NullLog_DiscardsEverything()
{
NullActivityLog log = NullActivityLog.Instance;
log.Info("a");
log.Warning("b");
log.Error("c", new InvalidOperationException());
Assert.Equal("", log.LogDirectory);
Assert.Equal("", log.CurrentLogFilePath);
}
var log = new FileActivityLog(_temp.Path, "app", retentionDays: 3, time: _clock);
log.Info("first");
Assert.False(File.Exists(_temp.File("app-2026-03-12.log")));
Assert.True(File.Exists(_temp.File("app-2026-03-13.log")));
Assert.True(File.Exists(_temp.File("app-notadate.log")));
Assert.True(File.Exists(_temp.File("other-2020-01-01.log")));
// Already pruned today: a newly stale file survives until tomorrow's first write.
File.WriteAllText(_temp.File("app-2026-01-01.log"), "stale");
log.Info("second");
Assert.True(File.Exists(_temp.File("app-2026-01-01.log")));
_clock.Now = _clock.Now.AddDays(1);
log.Info("next day");
Assert.False(File.Exists(_temp.File("app-2026-01-01.log")));
Assert.False(File.Exists(_temp.File("app-2026-03-13.log")));
}
/// <summary>Prune: skips files it cannot delete.</summary>
[Fact]
public void Prune_SkipsFilesItCannotDelete()
{
string locked = _temp.File("app-2020-01-01.log");
File.WriteAllText(locked, "held open");
using(new FileStream(locked, FileMode.Open, FileAccess.Read, FileShare.None))
{
var log = new FileActivityLog(_temp.Path, "app", retentionDays: 1, time: _clock);
log.Info("still logs");
Assert.True(File.Exists(log.CurrentLogFilePath));
}
Assert.True(File.Exists(locked));
}
/// <summary>ZeroRetention: keeps everything.</summary>
[Fact]
public void ZeroRetention_KeepsEverything()
{
File.WriteAllText(_temp.File("app-2000-01-01.log"), "ancient");
var log = new FileActivityLog(_temp.Path, "app", retentionDays: 0, time: _clock);
log.Info("x");
Assert.Equal(0, log.RetentionDays);
Assert.True(File.Exists(_temp.File("app-2000-01-01.log")));
}
/// <summary>WriteFailures: are swallowed.</summary>
[Fact]
public void WriteFailures_AreSwallowed()
{
string notADirectory = _temp.File("file");
File.WriteAllText(notADirectory, "");
var log = new FileActivityLog(notADirectory, "app", time: _clock);
log.Info("goes nowhere");
log.Error("also nowhere", new InvalidOperationException());
Assert.Equal(notADirectory, log.LogDirectory);
}
/// <summary>ForApp: uses the apps logs folder and id.</summary>
[Fact]
public void ForApp_UsesTheAppsLogsFolderAndId()
{
var app = new AppInfo { Id = "my-app", DisplayName = "X" };
var paths = new AppPaths(app, _ => _temp.Path);
var log = FileActivityLog.ForApp(app, paths);
Assert.Equal(paths.LogsDirectory, log.LogDirectory);
Assert.Equal(FileActivityLog.DefaultRetentionDays, log.RetentionDays);
Assert.StartsWith("my-app-", Path.GetFileName(log.CurrentLogFilePath), StringComparison.Ordinal);
Assert.Throws<ArgumentNullException>(() => FileActivityLog.ForApp(null!, paths));
Assert.Throws<ArgumentNullException>(() => FileActivityLog.ForApp(app, null!));
}
/// <summary>Constructor: validates arguments.</summary>
[Fact]
public void Constructor_ValidatesArguments()
{
Assert.Throws<ArgumentException>(() => new FileActivityLog(" ", "app"));
Assert.Throws<ArgumentException>(() => new FileActivityLog(_temp.Path, ""));
Assert.NotNull(new FileActivityLog(_temp.Path, "app").CurrentLogFilePath);
}
/// <summary>NullLog: discards everything.</summary>
[Fact]
public void NullLog_DiscardsEverything()
{
NullActivityLog log = NullActivityLog.Instance;
log.Info("a");
log.Warning("b");
log.Error("c", new InvalidOperationException());
Assert.Equal("", log.LogDirectory);
Assert.Equal("", log.CurrentLogFilePath);
}
}
/// <summary>Tests for reading and filtering log tails.</summary>
public sealed class LogTailTests : IDisposable
{
private readonly TempDirectory _temp = new();
private readonly TempDirectory _temp = new();
public void Dispose() => _temp.Dispose();
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(@"Z:\does\not\exist.log")]
public void Read_MissingFile_IsEmpty(string? path)
{
LogSnapshot snapshot = LogTail.Read(path);
Assert.Empty(snapshot.Lines);
Assert.Equal(0, snapshot.Length);
}
[Fact]
public void Read_KeepsOnlyTheTail()
{
string path = _temp.File("a.log");
File.WriteAllLines(path, ["1", "2", "3", "4"]);
LogSnapshot snapshot = LogTail.Read(path, maxLines: 2);
Assert.Equal(["3", "4"], snapshot.Lines);
Assert.Equal(new FileInfo(path).Length, snapshot.Length);
}
/// <summary>Deletes the test&apos;s temporary folder.</summary>
public void Dispose() => _temp.Dispose();
[Fact]
public void Read_LockedFile_ReturnsAnExplanation()
{
string path = _temp.File("locked.log");
File.WriteAllText(path, "x");
using var hold = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
LogSnapshot snapshot = LogTail.Read(path);
Assert.Equal("Could not read the log file.", snapshot.Lines[0]);
Assert.Equal(0, snapshot.Length);
}
[Fact]
public void Read_RejectsNonPositiveLineCounts()
{
Assert.Throws<ArgumentOutOfRangeException>(() => LogTail.Read("x", 0));
}
[Fact]
public void ErrorsOnly_KeepsErrorEntriesWithContinuationLines()
{
string[] lines =
[
"stray line before any entry",
/// <summary>Read: missing file is empty.</summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(@"Z:\does\not\exist.log")]
public void Read_MissingFile_IsEmpty(string? path)
{
LogSnapshot snapshot = LogTail.Read(path);
Assert.Empty(snapshot.Lines);
Assert.Equal(0, snapshot.Length);
}
/// <summary>Read: keeps only the tail.</summary>
[Fact]
public void Read_KeepsOnlyTheTail()
{
string path = _temp.File("a.log");
File.WriteAllLines(path, ["1", "2", "3", "4"]);
LogSnapshot snapshot = LogTail.Read(path, maxLines: 2);
Assert.Equal(["3", "4"], snapshot.Lines);
Assert.Equal(new FileInfo(path).Length, snapshot.Length);
}
/// <summary>Read: locked file returns an explanation.</summary>
[Fact]
public void Read_LockedFile_ReturnsAnExplanation()
{
string path = _temp.File("locked.log");
File.WriteAllText(path, "x");
using var hold = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
LogSnapshot snapshot = LogTail.Read(path);
Assert.Equal("Could not read the log file.", snapshot.Lines[0]);
Assert.Equal(0, snapshot.Length);
}
/// <summary>Read: rejects non positive line counts.</summary>
[Fact]
public void Read_RejectsNonPositiveLineCounts()
{
Assert.Throws<ArgumentOutOfRangeException>(() => LogTail.Read("x", 0));
}
/// <summary>ErrorsOnly: keeps error entries with continuation lines.</summary>
[Fact]
public void ErrorsOnly_KeepsErrorEntriesWithContinuationLines()
{
string[] lines =
[
"stray line before any entry",
"2026-01-01 10:00:00.000 [INFO] fine",
"2026-01-01 10:00:01.000 [ERROR] broke",
"System.Exception: detail",
" at Somewhere()",
"2026-01-01 10:00:02.000 [WARN] meh",
];
Assert.Equal(
["2026-01-01 10:00:01.000 [ERROR] broke", "System.Exception: detail", " at Somewhere()"],
LogTail.ErrorsOnly(lines));
}
Assert.Equal(
["2026-01-01 10:00:01.000 [ERROR] broke", "System.Exception: detail", " at Somewhere()"],
LogTail.ErrorsOnly(lines));
}
[Fact]
public void ToDisplayText_HasFriendlyEmptyStates()
{
Assert.Equal("No log entries yet.", LogTail.ToDisplayText([], errorsOnly: false));
Assert.Equal("No errors logged.", LogTail.ToDisplayText([], errorsOnly: true));
Assert.Equal("a" + Environment.NewLine + "b", LogTail.ToDisplayText(["a", "b"], errorsOnly: false));
}
[Fact]
public void NullArguments_Throw()
{
Assert.Throws<ArgumentNullException>(() => LogTail.ErrorsOnly(null!));
Assert.Throws<ArgumentNullException>(() => LogTail.ToDisplayText(null!, false));
}
/// <summary>ToDisplayText: has friendly empty states.</summary>
[Fact]
public void ToDisplayText_HasFriendlyEmptyStates()
{
Assert.Equal("No log entries yet.", LogTail.ToDisplayText([], errorsOnly: false));
Assert.Equal("No errors logged.", LogTail.ToDisplayText([], errorsOnly: true));
Assert.Equal("a" + Environment.NewLine + "b", LogTail.ToDisplayText(["a", "b"], errorsOnly: false));
}
/// <summary>NullArguments: throw.</summary>
[Fact]
public void NullArguments_Throw()
{
Assert.Throws<ArgumentNullException>(() => LogTail.ErrorsOnly(null!));
Assert.Throws<ArgumentNullException>(() => LogTail.ToDisplayText(null!, false));
}
}
+105 -71
View File
@@ -1,92 +1,126 @@
/*
* Copyright (c) 2026 James Maes (KofTwentyTwo)
* SPDX-License-Identifier: MIT
*/
using System.ComponentModel;
using KofTwentyTwo.AppKit.Secrets;
namespace KofTwentyTwo.AppKit.Tests;
/// <summary>Tests for the in-memory secret vault.</summary>
public class InMemorySecretVaultTests
{
[Fact]
public void StoreRetrieveDelete()
{
var vault = new InMemorySecretVault();
Assert.Null(vault.TryRetrieve("k"));
vault.Store("k", "v1");
vault.Store("k", "v2");
Assert.Equal("v2", vault.TryRetrieve("k"));
vault.Delete("k");
vault.Delete("k");
Assert.Null(vault.TryRetrieve("k"));
}
/// <summary>Store, overwrite, retrieve, and delete behave like a dictionary, and deleting twice is harmless.</summary>
[Fact]
public void StoreRetrieveDelete()
{
var vault = new InMemorySecretVault();
Assert.Null(vault.TryRetrieve("k"));
vault.Store("k", "v1");
vault.Store("k", "v2");
Assert.Equal("v2", vault.TryRetrieve("k"));
vault.Delete("k");
vault.Delete("k");
Assert.Null(vault.TryRetrieve("k"));
}
[Fact]
public void ValidatesArguments()
{
var vault = new InMemorySecretVault();
Assert.Throws<ArgumentException>(() => vault.Store("", "v"));
Assert.Throws<ArgumentNullException>(() => vault.Store("k", null!));
Assert.Throws<ArgumentNullException>(() => vault.TryRetrieve(null!));
Assert.Throws<ArgumentException>(() => vault.Delete(""));
}
/// <summary>Empty keys and null secrets are rejected.</summary>
[Fact]
public void ValidatesArguments()
{
var vault = new InMemorySecretVault();
Assert.Throws<ArgumentException>(() => vault.Store("", "v"));
Assert.Throws<ArgumentNullException>(() => vault.Store("k", null!));
Assert.Throws<ArgumentNullException>(() => vault.TryRetrieve(null!));
Assert.Throws<ArgumentException>(() => vault.Delete(""));
}
}
/// <summary>Runs against the real per-user Credential Manager under a unique prefix.</summary>
public sealed class CredentialManagerVaultTests : IDisposable
{
private readonly CredentialManagerVault _vault = new("appkit-test-" + Guid.NewGuid().ToString("N"));
private readonly CredentialManagerVault _vault = new("appkit-test-" + Guid.NewGuid().ToString("N"));
public void Dispose()
{
_vault.Delete("token");
_vault.Delete("empty");
}
[Fact]
public void StoreRetrieveOverwriteDelete()
{
Assert.Null(_vault.TryRetrieve("token"));
_vault.Store("token", "ghp_first");
_vault.Store("token", "ghp_second ✓");
Assert.Equal("ghp_second ✓", _vault.TryRetrieve("token"));
_vault.Delete("token");
_vault.Delete("token");
Assert.Null(_vault.TryRetrieve("token"));
}
[Fact]
public void EmptySecret_RoundTrips()
{
_vault.Store("empty", "");
Assert.Equal("", _vault.TryRetrieve("empty"));
}
/// <summary>Removes the credentials the test may have created.</summary>
public void Dispose()
{
_vault.Delete("token");
_vault.Delete("empty");
}
[Fact]
public void TargetName_IsPrefixColonKey()
{
var vault = new CredentialManagerVault("gclo");
Assert.Equal("gclo:account:abc", vault.TargetName("account:abc"));
Assert.Throws<ArgumentException>(() => vault.TargetName(""));
}
[Fact]
public void OversizedSecret_FailsLoudly()
{
// The Credential Manager caps blobs at 2560 bytes; silently losing a secret is worse than throwing.
var ex = Assert.Throws<Win32Exception>(() => _vault.Store("token", new string('x', 5000)));
Assert.Contains("CredWriteW", ex.Message, StringComparison.Ordinal);
Assert.Throws<ArgumentNullException>(() => _vault.Store("token", null!));
}
[Fact]
public void InvalidTargets_SurfaceNativeErrors()
{
Assert.Throws<Win32Exception>(() => CredentialManagerVault.TryRetrieveTarget("", "k"));
Assert.Throws<Win32Exception>(() => CredentialManagerVault.DeleteTarget("", "k"));
}
/// <summary>A secret round-trips through the Credential Manager, overwrites in place, and deletes cleanly.</summary>
[Fact]
public void StoreRetrieveOverwriteDelete()
{
Assert.Null(_vault.TryRetrieve("token"));
_vault.Store("token", "ghp_first");
_vault.Store("token", "ghp_second ✓");
Assert.Equal("ghp_second ✓", _vault.TryRetrieve("token"));
_vault.Delete("token");
_vault.Delete("token");
Assert.Null(_vault.TryRetrieve("token"));
}
[Fact]
public void Constructor_ValidatesPlatformAndPrefix()
{
Assert.Throws<PlatformNotSupportedException>(() => new CredentialManagerVault("x", isWindows: false));
Assert.Throws<ArgumentException>(() => new CredentialManagerVault(" "));
}
/// <summary>EmptySecret: round trips.</summary>
[Fact]
public void EmptySecret_RoundTrips()
{
_vault.Store("empty", "");
Assert.Equal("", _vault.TryRetrieve("empty"));
}
/// <summary>TargetName: is prefix colon key.</summary>
[Fact]
public void TargetName_IsPrefixColonKey()
{
var vault = new CredentialManagerVault("gclo");
Assert.Equal("gclo:account:abc", vault.TargetName("account:abc"));
Assert.Throws<ArgumentException>(() => vault.TargetName(""));
}
/// <summary>OversizedSecret: fails loudly.</summary>
[Fact]
public void OversizedSecret_FailsLoudly()
{
// The Credential Manager caps blobs at 2560 bytes; silently losing a secret is worse than throwing.
Win32Exception ex = Assert.Throws<Win32Exception>(() => _vault.Store("token", new string('x', 5000)));
Assert.Contains("CredWriteW", ex.Message, StringComparison.Ordinal);
Assert.Throws<ArgumentNullException>(() => _vault.Store("token", null!));
}
/// <summary>InvalidTargets: surface native errors.</summary>
[Fact]
public void InvalidTargets_SurfaceNativeErrors()
{
Assert.Throws<Win32Exception>(() => CredentialManagerVault.TryRetrieveTarget("", "k"));
Assert.Throws<Win32Exception>(() => CredentialManagerVault.DeleteTarget("", "k"));
}
/// <summary>Constructor: validates platform and prefix.</summary>
[Fact]
public void Constructor_ValidatesPlatformAndPrefix()
{
Assert.Throws<PlatformNotSupportedException>(() => new CredentialManagerVault("x", isWindows: false));
Assert.Throws<ArgumentException>(() => new CredentialManagerVault(" "));
}
}
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more