chore: adopt the KofTwentyTwo standards #2
No files matched your search
+174
-23
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/KofTwentyTwo/AppKit)
|
||||
[](LICENSE)
|
||||

|
||||

|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| **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
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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>
|
||||
@@ -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'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>
|
||||
@@ -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>
|
||||
@@ -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'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.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'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.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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's install and update hooks first, then creates the WPF application with AppKit'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
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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'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'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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'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;
|
||||
}
|
||||
@@ -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>() => 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;
|
||||
}
|
||||
}
|
||||
@@ -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>() => 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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'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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 "+<metadata>" 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 "+<metadata>" 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();
|
||||
}
|
||||
@@ -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: <data>\logs\<id>-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: <data>\logs\<id>-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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 "<targetPrefix>:<key>".</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 "<targetPrefix>:<key>".</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);
|
||||
}
|
||||
@@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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'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'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));
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user