feat: initialize Windows MarkdownViewr with AppKit

Generate the GUI-only AppKit foundation, lock published dependencies and record the Windows feature requirements against the pinned original. The greeting is still scaffold content; no Markdown viewer or release is claimed.

Validation: zero-warning solution build; 20 core tests; 100% core coverage; formatting and documentation checks.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
Signed-off-by: James Maes <jmaes@dmdbrands.com>
This commit is contained in:
James MaesandCodex committed 2026-10-10 18:59:10 -05:00
commit 06f083af39
92 files changed
+8027

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
# 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 = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
# Kingsrook: always 3 spaces, never tabs.
indent_size = 3
tab_width = 3
# 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]
#### 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
#### 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
#### 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
#### 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
#### 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
#### 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
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 = namespace, class, struct, enum, delegate, property, method, event
dotnet_naming_style.pascal_case.capitalization = pascal_case
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
# 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
[src/MarkdownViewr.Windows/**.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
View File
@@ -0,0 +1,6 @@
# 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
+3
View File
@@ -0,0 +1,3 @@
# 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
+40
View File
@@ -0,0 +1,40 @@
name: Bug report
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/MarkdownViewr.Windows/security/advisories/new).
- type: input
id: version
attributes:
label: Version
description: The exact version (Help, About, or `--version`).
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened, and what did you expect?
description: Include exact error messages.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: OS and version, and anything else relevant.
- type: textarea
id: logs
attributes:
label: Logs
description: Relevant log lines. Remove any tokens, passwords, or personal data first.
render: text
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Report a security vulnerability
url: https://github.com/KofTwentyTwo/MarkdownViewr.Windows/security/advisories/new
about: Please report security issues privately, never as a public issue.
@@ -0,0 +1,20 @@
name: Feature request
description: Suggest an improvement
labels: [enhancement]
body:
- type: textarea
id: problem
attributes:
label: What problem would this solve?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
+23
View File
@@ -0,0 +1,23 @@
<!-- Title: a Conventional Commit header, e.g. "fix(sync): retry on transient network errors". -->
## What
<!-- What changes, in a sentence or two. -->
## Why
<!-- 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
- [ ] 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
+43
View File
@@ -0,0 +1,43 @@
# 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: npm
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
# Always: keeps SHA-pinned actions and reusable workflows current (K22-CI-11).
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
cooldown:
default-days: 7
groups:
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.
+19
View File
@@ -0,0 +1,19 @@
# Categories for GitHub's auto-generated release notes (uses issue/PR labels).
changelog:
exclude:
labels: [deferred]
categories:
- title: Fixed
labels: [bug]
- title: Added
labels: [enhancement]
- title: Quality & internals
labels: [quality, refactor]
- title: Release & packaging
labels: [release]
- title: Documentation
labels: [documentation]
- title: Dependencies
labels: [dependencies]
- title: Other changes
labels: ["*"]
+74
View File
@@ -0,0 +1,74 @@
// Copyright (c) 2026 James Maes (KofTwentyTwo)
// SPDX-License-Identifier: MIT
"use strict";
const acknowledgement = "Maintainer acknowledgement: I read the review and resolved or answered every finding.";
/** Accepts only the latest maintainer-owned record for the current commit. */
function evaluateReviewRecord(comments, sha, maintainer)
{
const marker = `<!-- k22-automated-review:${sha} -->`;
const records = comments.filter(comment =>
comment.user?.login === maintainer
&& comment.user?.type === "User"
&& typeof comment.body === "string"
&& comment.body.includes(marker));
records.sort((left, right) =>
String(right.updated_at).localeCompare(String(left.updated_at)) || right.id - left.id);
const record = records[0];
if(!record)
{
return { passed: false, reason: "No maintainer-owned automated review record exists for the current PR commit." };
}
const lines = record.body.split(/\r?\n/).map(line => line.trim());
const field = name =>
{
const values = lines.filter(line => line.startsWith(`${name}:`));
return values.length === 1 ? values[0].slice(name.length + 1).trim() : null;
};
const passed = lines.includes(marker)
&& ["Codex", "Claude", "Copilot"].includes(field("Tool"))
&& ["none", "resolved"].includes(field("Findings"))
&& (field("Summary")?.length ?? 0) >= 20
&& lines.includes(acknowledgement);
return { passed, reason: passed ? "Current commit reviewed and acknowledged by the maintainer."
: "The latest review record is incomplete or still has unresolved findings." };
}
/** Publishes a check on the PR head without ever fetching or executing its code. */
async function run({ github, context, core })
{
const { owner, repo } = context.repo;
const pullNumber = context.payload.pull_request?.number ?? context.payload.issue?.number;
const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number: pullNumber });
if(pull.state !== "open" || pull.base.ref !== "main")
{
core.info("Only open PRs targeting main require a review record.");
return;
}
const sha = pull.head.sha;
// Start failed: an API error must never leave an apparently successful gate.
const { data: check } = await github.rest.checks.create({
owner, repo, name: "review / automated", head_sha: sha,
status: "completed", conclusion: "failure",
output: { title: "Automated review required", summary: "Waiting for validation of the current commit review record." },
});
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pullNumber, per_page: 100,
});
const result = evaluateReviewRecord(comments, sha, "@KofTwentyTwo".slice(1));
await github.rest.checks.update({
owner, repo, check_run_id: check.id,
status: "completed", conclusion: result.passed ? "success" : "failure",
output: { title: result.passed ? "Automated review recorded" : "Automated review required", summary: result.reason },
});
if(!result.passed)
{
core.setFailed(result.reason);
}
}
module.exports = { evaluateReviewRecord, run };
+144
View File
@@ -0,0 +1,144 @@
// Copyright (c) 2026 James Maes (KofTwentyTwo)
// SPDX-License-Identifier: MIT
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { evaluateReviewRecord, run } = require("./review-gate.cjs");
const sha = "a".repeat(40);
/** A maintainer-recorded review of the current commit. */
function record(overrides = {})
{
return {
id: 1, updated_at: "2026-10-10T12:00:00Z", user: { login: "@KofTwentyTwo".slice(1), type: "User" },
body: `<!-- k22-automated-review:${sha} -->\nTool: Codex\nFindings: none\n`
+ "Summary: Reviewed input validation, workflow permissions, and regression coverage.\n"
+ "Maintainer acknowledgement: I read the review and resolved or answered every finding.",
...overrides,
};
}
test("accepts a current complete record from the maintainer", () =>
{
assert.equal(evaluateReviewRecord([record()], sha, "@KofTwentyTwo".slice(1)).passed, true);
});
test("rejects stale commits, outsiders, bots, missing acknowledgement and unresolved findings", () =>
{
const cases = [
[],
[record({ body: record().body.replace(sha, "b".repeat(40)) })],
[record({ user: { login: "contributor", type: "User" } })],
[record({ user: { login: "@KofTwentyTwo".slice(1), type: "Bot" } })],
[record({ body: record().body.replace("Maintainer acknowledgement:", "Pending acknowledgement:") })],
[record({ body: record().body.replace("Findings: none", "Findings: unresolved") })],
[record({ body: record().body.replace("Summary:", "No summary:") })],
[record({ body: record().body + "\nFindings: unresolved" })],
[record({ body: record().body + "\nTool: Unknown" })],
[record({ body: record().body.replace("Tool: Codex", "Tool: Unknown") })],
[record({ body: record().body.replace("Summary: Reviewed input validation, workflow permissions, and regression coverage.", "Summary: Too short.") })],
];
for(const comments of cases)
{
assert.equal(evaluateReviewRecord(comments, sha, "@KofTwentyTwo".slice(1)).passed, false);
}
});
test("a newer unresolved record overrides an older passing record", () =>
{
const latest = record({ id: 2, updated_at: "2026-10-10T13:00:00Z", body: record().body.replace("Findings: none", "Findings: unresolved") });
assert.equal(evaluateReviewRecord([record(), latest], sha, "@KofTwentyTwo".slice(1)).passed, false);
});
test("an edited record supersedes the previous review", () =>
{
const updated = record({ updated_at: "2026-10-10T14:00:00Z", body: record().body.replace("Findings: none", "Findings: resolved") });
const previous = record({ id: 2, updated_at: "2026-10-10T13:00:00Z", body: record().body.replace("Findings: none", "Findings: unresolved") });
assert.equal(evaluateReviewRecord([previous, updated], sha, "@KofTwentyTwo".slice(1)).passed, true);
});
test("publishes to the live PR head and paginates review comments", async () =>
{
const writes = [];
const github = {
rest: {
pulls: { get: async () => ({ data: { state: "open", base: { ref: "main" }, head: { sha } } }) },
issues: { listComments: "comments" },
checks: {
create: async input =>
{
writes.push(input); return { data: { id: 9 } };
},
update: async input =>
{
writes.push(input);
},
},
},
paginate: async (method, input) =>
{
assert.equal(method, "comments");
assert.equal(input.issue_number, 7);
return [record()];
},
};
await run({ github, context: { repo: { owner: "@KofTwentyTwo".slice(1), repo: "AppKit" }, payload: { issue: { number: 7 } } },
core: { setFailed: assert.fail, info: () =>
{} } });
assert.equal(writes[0].head_sha, sha);
assert.equal(writes[0].conclusion, "failure");
assert.equal(writes[1].check_run_id, 9);
assert.equal(writes[1].conclusion, "success");
});
test("an API failure leaves the current head check failed", async () =>
{
const writes = [];
const github = {
rest: {
pulls: { get: async () => ({ data: { state: "open", base: { ref: "main" }, head: { sha } } }) },
issues: { listComments: "comments" },
checks: {
create: async input =>
{
writes.push(input); return { data: { id: 9 } };
},
update: async input =>
{
writes.push(input);
},
},
},
paginate: async () =>
{
throw new Error("API unavailable");
},
};
await assert.rejects(run({ github,
context: { repo: { owner: "@KofTwentyTwo".slice(1), repo: "AppKit" }, payload: { issue: { number: 7 } } },
core: { setFailed: assert.fail, info: () =>
{} },
}), /API unavailable/);
assert.equal(writes.length, 1);
assert.equal(writes[0].head_sha, sha);
assert.equal(writes[0].conclusion, "failure");
});
test("ignores closed PRs and PRs targeting another branch", async () =>
{
for(const pull of [{ state: "closed", base: { ref: "main" } }, { state: "open", base: { ref: "other" } }])
{
let ignored = false;
await run({
github: { rest: { pulls: { get: async () => ({ data: pull }) } } },
context: { repo: { owner: "@KofTwentyTwo".slice(1), repo: "AppKit" }, payload: { issue: { number: 7 } } },
core: { info: () =>
{
ignored = true;
}, setFailed: assert.fail },
});
assert.equal(ignored, true);
}
});
+65
View File
@@ -0,0 +1,65 @@
# .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 UI-free core at 100% line coverage.
name: ci
on:
pull_request:
push:
branches: [main]
workflow_call:
permissions: {}
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
installed-updates:
name: installed-updates
runs-on: windows-latest
timeout-minutes: 45
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
- name: Install pinned Velopack and verify installed channels
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$tools = Join-Path $env:RUNNER_TEMP 'installed-smoke-tools'
dotnet tool install vpk --version 1.2.161 --tool-path $tools
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
./build/Test-InstalledUpdates.ps1 -VpkPath (Join-Path $tools 'vpk.exe')
- name: Retain runtime evidence
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: installed-update-results
path: |
artifacts/installed-smoke-*/*.json
artifacts/installed-smoke-*/*.log
artifacts/installed-smoke-*/*.trx
retention-days: 7
ci:
uses: KofTwentyTwo/standards/.github/workflows/dotnet.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2
permissions:
contents: read # check out, build, and test; nothing is written
with:
solution: MarkdownViewr.Windows.slnx
platform: x64
coverage-packages: MarkdownViewr.Windows.Core
test-projects: |
tests/MarkdownViewr.Windows.Core.Tests
ui-test-projects: |
tests/MarkdownViewr.Windows.UiTests
+31
View File
@@ -0,0 +1,31 @@
# 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]
schedule:
- cron: "41 6 * * 1"
workflow_call:
permissions: {}
concurrency:
group: codeql-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
codeql:
uses: KofTwentyTwo/standards/.github/workflows/codeql.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2
permissions:
contents: read # check out the code
security-events: write # upload CodeQL results to code scanning
with:
languages: '["csharp", "actions", "javascript-typescript"]'
build-mode: manual
build-command: dotnet build MarkdownViewr.Windows.slnx -p:Platform=x64
runs-on: windows-latest
setup-dotnet: true
+20
View File
@@ -0,0 +1,20 @@
# Pull request gates (K22-CI-01): Conventional Commit title, DCO sign-off, and
# dependency review, from the shared KofTwentyTwo/standards workflow pinned by SHA
# (K22-CI-11). Dependabot's github-actions ecosystem keeps the pin current.
name: pr-gates
on:
pull_request:
permissions: {}
concurrency:
group: pr-gates-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
pr:
uses: KofTwentyTwo/standards/.github/workflows/pr.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2
permissions:
contents: read # read the commits being merged
pull-requests: read # read the pull request title and dependency diff
+190
View File
@@ -0,0 +1,190 @@
# Windows desktop application release for a KofTwentyTwo repository (K22-CI-30,
# K22-REL-*, architecture/windows-desktop-app.md).
# Pushing a protected v* tag builds, tests, publishes, packs with Velopack, generates
# SBOMs, attests, and publishes the GitHub release through the shared reusable workflow.
#
# Tags: v1.2.3 -> Velopack channel `stable`; v1.2.3-beta.1 -> channel `dev` (prerelease).
#
# One-time owner setup:
# - Environment `release` (Settings -> Environments) with a deployment rule that allows
# only tags matching v*.
# - Configure verified Azure signing variables and a repository/environment-bound
# OIDC identity; pin the published reviewed builder, enable sign and declare
# the owned-file catalog. See docs/release-signing.md. Unsigned publication is blocked.
# - winget (optional, stable releases only): the package must already exist in
# microsoft/winget-pkgs. Submit it once by hand with
# wingetcreate new <Setup.exe URL of the first stable release>
# then add a WINGET_TOKEN secret (a fine-grained token that can fork and open pull
# requests on public repositories, expiring within 90 days, K22-SEC-03) to the
# `release` environment, and set the repository variable WINGET_PACKAGE_ID.
name: release
on:
push:
tags:
- "v*"
permissions: {}
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
verify:
name: verify
runs-on: ubuntu-24.04
timeout-minutes: 10
environment: release
permissions:
contents: read
steps:
- name: Checkout main and tag history
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Require the tagged commit to be on main
shell: bash
run: |
set -euo pipefail
git merge-base --is-ancestor "$GITHUB_SHA" origin/main
- name: Require verified release signing configuration
shell: pwsh
env:
AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
ARTIFACT_SIGNING_ENDPOINT: ${{ vars.ARTIFACT_SIGNING_ENDPOINT || vars.TRUSTED_SIGNING_ENDPOINT }}
ARTIFACT_SIGNING_ACCOUNT: ${{ vars.ARTIFACT_SIGNING_ACCOUNT || vars.TRUSTED_SIGNING_ACCOUNT }}
ARTIFACT_SIGNING_PROFILE: ${{ vars.ARTIFACT_SIGNING_PROFILE || vars.TRUSTED_SIGNING_PROFILE }}
ARTIFACT_SIGNING_SUBJECT: ${{ vars.ARTIFACT_SIGNING_SUBJECT || vars.TRUSTED_SIGNING_SUBJECT }}
run: ./build/Assert-ReleaseSigning.ps1
gates:
needs: verify
uses: ./.github/workflows/ci.yml
permissions:
contents: read
security:
needs: verify
uses: ./.github/workflows/security.yml
permissions:
contents: read
security-events: write
codeql:
needs: verify
uses: ./.github/workflows/codeql.yml
permissions:
contents: read
security-events: write
scripts:
needs: verify
uses: ./.github/workflows/scripts.yml
permissions:
contents: read
release:
needs: [verify, gates, security, codeql, scripts]
uses: KofTwentyTwo/standards/.github/workflows/release-velopack.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2
permissions:
contents: write # create and publish the GitHub release
id-token: write # sign attestations; Azure OIDC when signing
attestations: write # store attestations
with:
solution: MarkdownViewr.Windows.slnx
app-project: src/MarkdownViewr.Windows/MarkdownViewr.Windows.csproj
app-id: markdownviewr-windows
main-exe: MarkdownViewr.Windows.exe
icon: src/MarkdownViewr.Windows/Assets/Brand/app.ico
platform: x64
runtime: win-x64
coverage-packages: MarkdownViewr.Windows.Core
test-projects: |
tests/MarkdownViewr.Windows.Core.Tests
sign: false # Preflight blocks publication until the reviewed signing builder is published and configured.
# winget lives here, not in the shared workflow, because its token belongs to this
# repository's `release` environment. It runs only for stable releases, and only when
# the WINGET_PACKAGE_ID variable is set (after the manual first submission).
winget:
name: winget
needs: release
if: needs.release.outputs.prerelease == 'false' && vars.WINGET_PACKAGE_ID != ''
runs-on: windows-latest
timeout-minutes: 15
environment: release
permissions: {}
steps:
- name: Submit winget manifest update
shell: pwsh
env:
WINGET_CREATE_GITHUB_TOKEN: ${{ secrets.WINGET_TOKEN }}
WINGET_PACKAGE_ID: ${{ vars.WINGET_PACKAGE_ID }}
K22_VERSION: ${{ needs.release.outputs.version }}
SETUP_URL: ${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ needs.release.outputs.setup }}
run: |
if (-not $env:WINGET_CREATE_GITHUB_TOKEN)
{
Write-Output '::error::WINGET_PACKAGE_ID is configured but WINGET_TOKEN is missing from the release environment.'
exit 1
}
# wingetcreate v1.12.13.0, pinned by URL and verified by SHA-256 before use.
$wingetcreate = Join-Path $env:RUNNER_TEMP 'wingetcreate.exe'
Invoke-WebRequest -Uri 'https://github.com/microsoft/winget-create/releases/download/v1.12.13.0/wingetcreate.exe' -OutFile $wingetcreate
if ((Get-FileHash -LiteralPath $wingetcreate -Algorithm SHA256).Hash -ne '24042BD37915805615E6CF969AC57C6439124C3FE85823327F5F3FB24BD9FFEA')
{
Write-Output '::error::wingetcreate.exe does not match the pinned SHA-256.'
exit 1
}
& $wingetcreate update $env:WINGET_PACKAGE_ID --urls $env:SETUP_URL --version $env:K22_VERSION --submit
distribution:
name: distribution candidates
needs: release
if: needs.release.outputs.prerelease == 'false' && (vars.DISTRIBUTION_ARTIFACT == 'portable' || vars.DISTRIBUTION_ARTIFACT == 'cli')
runs-on: windows-latest
timeout-minutes: 15
environment: release
permissions:
contents: read
steps:
- name: Checkout reviewed release
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download and verify final stable artifact
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
K22_REPOSITORY: ${{ github.repository }}
K22_TAG: ${{ github.ref_name }}
K22_VERSION: ${{ needs.release.outputs.version }}
K22_KIND: ${{ vars.DISTRIBUTION_ARTIFACT }}
run: |
$ErrorActionPreference = 'Stop'
if ($env:K22_KIND -eq 'cli') { throw 'CLI distribution requires a generated CLI head.' }
$asset = if ($env:K22_KIND -eq 'cli') { "markdownviewr-windows-cli-win-x64-$env:K22_VERSION.zip" } else { 'markdownviewr-windows-stable-Portable.zip' }
New-Item -ItemType Directory -Force -Path artifacts/distribution-input | Out-Null
gh release download $env:K22_TAG --repo $env:K22_REPOSITORY --pattern $asset --pattern SHA256SUMS --dir artifacts/distribution-input
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$path = Join-Path 'artifacts/distribution-input' $asset
$hash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
$line = @(Get-Content artifacts/distribution-input/SHA256SUMS | Where-Object { $_ -match "^[0-9a-fA-F]{64}\s+\*?$([regex]::Escape($asset))$" })
if ($line.Count -ne 1 -or $line[0].Substring(0, 64).ToLowerInvariant() -ne $hash) { throw 'Release archive does not match SHA256SUMS.' }
gh attestation verify $path --repo $env:K22_REPOSITORY --signer-repo KofTwentyTwo/standards
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
./build/New-DistributionManifests.ps1 -Version $env:K22_VERSION -ArtifactPath $path -ArtifactKind $env:K22_KIND
$spec = @(Get-ChildItem artifacts/distribution/chocolatey -Recurse -Filter '*.nuspec')
if ($spec.Count -ne 1) { throw 'Expected one selected Chocolatey package.' }
choco pack $spec[0].FullName --outputdirectory artifacts/distribution
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Upload candidates for review
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: distribution-candidates
path: artifacts/distribution
if-no-files-found: error
+36
View File
@@ -0,0 +1,36 @@
# Single-maintainer automated review record (K22-SDLC-12 / EX-0001).
# Privileged events execute only code from the trusted workflow commit, never a PR head.
name: review
on:
issue_comment:
types: [created, edited, deleted]
permissions: {}
concurrency:
group: review-${{ github.event.issue.number }}
cancel-in-progress: true
jobs:
record:
if: github.event.issue.pull_request
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
pull-requests: read
issues: read
checks: write # publish the required check on the live PR head
steps:
- name: Checkout trusted gate
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.workflow_sha }}
persist-credentials: false
- name: Verify the current commit review record
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
const { run } = require('./.github/scripts/review-gate.cjs');
await run({ github, context, core });
+54
View File
@@ -0,0 +1,54 @@
# OpenSSF Scorecard (K22-CI-24). Scorecard's publishing API only accepts results
# from a workflow shaped exactly like this one: a single job, read-only top-level
# permissions, no top-level env or defaults, and only the allowed steps.
name: scorecard
on:
branch_protection_rule:
schedule:
- cron: "17 7 * * 1"
push:
branches: [main]
workflow_dispatch:
permissions: {}
concurrency:
group: scorecard-${{ github.ref }}
cancel-in-progress: false
jobs:
analysis:
name: analysis
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
actions: read # Scorecard reads workflow run metadata
security-events: write # upload SARIF to code scanning
id-token: write # publish results to the OpenSSF API and badge
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Run Scorecard
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
with:
results_file: scorecard.sarif
results_format: sarif
publish_results: true
- name: Keep the results
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: scorecard-sarif
path: scorecard.sarif
retention-days: 30
- name: Upload SARIF to code scanning
uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
sarif_file: scorecard.sarif
category: scorecard
+71
View File
@@ -0,0 +1,71 @@
# K22-CODE-SH: check repository tooling before merges and releases.
name: scripts
on:
pull_request:
push:
branches: [main]
workflow_call:
permissions: {}
jobs:
javascript:
name: javascript
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version: "24"
- name: Install locked tools without lifecycle scripts
run: npm ci --ignore-scripts
- name: Lint and test review validation
run: |
npm run lint
npm test
- name: Verify dependency signatures
run: npm audit signatures
powershell:
name: powershell
runs-on: windows-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install pinned analyzer and check scripts
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser -Force
Import-Module PSScriptAnalyzer -RequiredVersion 1.25.0
# Analyze each file separately: recursive analysis can crash inside 1.25.0.
foreach($script in Get-ChildItem -LiteralPath build -Recurse -File | Where-Object Extension -In '.ps1', '.psm1', '.psd1')
{
$findings = @(Invoke-ScriptAnalyzer -Path $script.FullName -Settings ./PSScriptAnalyzerSettings.psd1)
if($findings.Count -gt 0) { $findings | Format-Table; throw "Script analysis failed: $($script.Name)" }
}
- name: Verify script boundary tests
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force
Import-Module Pester -RequiredVersion 5.7.1
$testConfiguration = New-PesterConfiguration
$testConfiguration.Run.Path = @('build/Assert-Coverage.Tests.ps1', 'build/GitHubRepository.Tests.ps1', 'build/Distribution.Tests.ps1', 'build/InstalledUpdates.Tests.ps1', 'build/PackageBytes.Tests.ps1', 'build/ReleaseSigning.Tests.ps1')
$testConfiguration.Run.Exit = $true
$testConfiguration.TestResult.Enabled = $true
$testConfiguration.TestResult.OutputPath = 'TestResults/coverage-gate.xml'
Invoke-Pester -Configuration $testConfiguration
+27
View File
@@ -0,0 +1,27 @@
# 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:
workflow_call:
permissions: {}
concurrency:
group: security-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
security:
uses: KofTwentyTwo/standards/.github/workflows/security.yml@fdf5ed029603c938615a32b1beca3628b8787945 # v0.1.2
permissions:
contents: read # check out the code and its history
security-events: write # upload Trivy and zizmor SARIF to code scanning
+488
View File
@@ -0,0 +1,488 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp
# WinUI packaged apps need their publish profiles in source control
!**/Properties/PublishProfiles/*.pubxml
# Default Pester CI report from local runs.
testResults.xml
+68
View File
@@ -0,0 +1,68 @@
# KofTwentyTwo pre-commit configuration. Works with pre-commit and prek.
# Copy to the repository root as .pre-commit-config.yaml, then install both hook
# types once per clone:
#
# pre-commit install --hook-type pre-commit --hook-type commit-msg
#
# Every external hook is pinned to a full commit SHA with the release in a comment
# (`pre-commit autoupdate --freeze` keeps that format). The hooks are the local half
# of the CI gates in standards/ci-cd.md; CI runs the same tools again.
default_install_hook_types: [pre-commit, commit-msg]
default_stages: [pre-commit]
repos:
# File hygiene.
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0
hooks:
- id: check-added-large-files
args: [--maxkb=1024]
- id: check-case-conflict
- id: check-merge-conflict
- id: check-yaml
- id: check-json
- id: check-toml
- id: detect-private-key
- id: end-of-file-fixer
- id: mixed-line-ending
args: [--fix=lf]
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
- id: no-commit-to-branch
args: [--branch, main]
# Secrets (K22-CI-20): staged changes only, so it stays fast.
- repo: https://github.com/gitleaks/gitleaks
rev: 83d9cd684c87d95d656c1458ef04895a7f1cbd8e # frozen: v8.30.1
hooks:
- id: gitleaks
# Workflow security (K22-CI-23).
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: fa412071e4f5d44d44f9e365f4676f9df92456a2 # frozen: v1.30.1
hooks:
- id: zizmor
args: [--min-severity, medium]
- repo: https://github.com/rhysd/actionlint
rev: 914e7df21a07ef503a81201c76d2b11c789d3fca # frozen: v1.7.12
hooks:
- id: actionlint
# Commit message: Conventional Commits header (K22-SDLC commit rules).
- repo: https://github.com/compilerla/conventional-pre-commit
rev: 91ab4bf57e58b32adf1a122681f6ebe164d081c8 # frozen: v4.4.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
args: [--strict, build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test]
# Commit message: DCO sign-off (`git commit -s`). pygrep with --negate fails when
# no line of the message matches.
- repo: local
hooks:
- id: dco-sign-off
name: DCO sign-off present (git commit -s)
language: pygrep
entry: '^Signed-off-by: .+ <.+@.+>$'
args: [--negate]
stages: [commit-msg]
+31
View File
@@ -0,0 +1,31 @@
# Repository Guidelines
## Purpose and Structure
MarkdownViewr.Windows is a Windows Markdown viewer built with AppKit and WinUI 3. Read `docs/PRODUCT.md`, `docs/TODO.md` and `docs/SESSION-STATE.md` before continuing. The initial greeting is scaffold content; do not claim the viewer is implemented until its requirements pass.
`src/MarkdownViewr.Windows.Core/` contains testable logic and view models. `src/MarkdownViewr.Windows/` contains WinUI views and Windows adapters; brand assets are in `Assets/Brand/`. Unit, desktop UI and installed-update probes live under `tests/`. `build/` contains coverage, packaging, setup and release validation tools.
## Build and Test
Use Windows and the .NET 10 SDK selected by `global.json`:
```powershell
dotnet restore MarkdownViewr.Windows.slnx -p:Platform=x64 --locked-mode
dotnet build MarkdownViewr.Windows.slnx -p:Platform=x64 -warnaserror
dotnet test tests/MarkdownViewr.Windows.Core.Tests --settings coverage.runsettings --collect:'XPlat Code Coverage' --results-directory TestResults
./build/Assert-Coverage.ps1 -ResultsDirectory TestResults
dotnet format MarkdownViewr.Windows.slnx --verify-no-changes --severity warn --no-restore
```
Desktop tests require an explicitly available interactive desktop. Run `dotnet test tests/MarkdownViewr.Windows.UiTests` only when input will not interfere with other work. Preserve 100% measured core coverage; use xUnit and deterministic injected time/filesystem boundaries. Follow `Method_Scenario_Expectation` test names.
## Style and Contributions
Follow `.editorconfig`: three-space C# indentation, Allman braces, file-scoped namespaces, PascalCase public members and `_camelCase` private fields. Keep type/method comments and three blank lines between members. YAML uses two spaces. Centralize versions in `Directory.Packages.props` and commit regenerated locks.
Use topic branches, Conventional Commits, signed/DCO commits (`git commit -S -s`), and PRs targeting `main`. Report actual validation, link issues, include UI screenshots and disclose substantial AI assistance. Install both pre-commit and commit-msg hooks. Never fabricate the required human review acknowledgement.
## Security and Continuity
Treat document content and renderer messages as untrusted; keep native access narrow. Preserve the original MIT notices when adapting Darin's code. Update the threat model for renderer/file-handling changes and maintain the session/task documents. Never commit secrets or publish unsigned production releases. Follow the repository release runbooks and existing authorization boundaries.
+37
View File
@@ -0,0 +1,37 @@
# MarkdownViewr
A KofTwentyTwo Windows desktop app (WinUI 3, .NET 10) built on the KofTwentyTwo AppKit
packages, following the KofTwentyTwo standards (github.com/KofTwentyTwo/standards) and
its Windows desktop reference architecture.
## Commands
```powershell
dotnet restore MarkdownViewr.Windows.slnx -p:Platform=x64 --locked-mode
dotnet build MarkdownViewr.Windows.slnx -p:Platform=x64 -warnaserror
dotnet test MarkdownViewr.Windows.slnx -p:Platform=x64 --no-build --filter "FullyQualifiedName!~UiTests" --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults
./build/Assert-Coverage.ps1 -ResultsDirectory TestResults
dotnet test tests/MarkdownViewr.Windows.UiTests --no-build # launches the app on the desktop
dotnet format MarkdownViewr.Windows.slnx --verify-no-changes --severity warn --no-restore
```
## Rules
- GitHub Flow: topic branch `<type>/<desc>` → PR to `main` → squash merge. Conventional
Commit titles, `git commit -S -s` (signature and DCO). Agents never merge, tag, release,
or change repository settings.
- `src/MarkdownViewr.Windows` holds views and WinUI plumbing only. Logic, view models, and settings go
in `src/MarkdownViewr.Windows.Core`, held to 100% line coverage. Shell features (splash, About, log
window, updates, theme, crash net) come from AppKit; do not re-implement them here.
- Zero warnings. Kingsrook style (`.editorconfig`): 3-space indent, Allman braces, `if(`,
LF, three blank lines between members, a `///` header comment on every type and method,
SPDX header on every file, one type per file.
- Package versions only in `Directory.Packages.props`; commit regenerated
`packages.lock.json` files; CI restores in locked mode.
- Attack-surface changes update `docs/security/threat-model.md`; expensive-to-reverse
decisions get an ADR in `docs/adr/`.
- The app id `markdownviewr-windows` names the data folder, logs, credentials, and Velopack package;
never change it after the first release (installed copies would lose their data and
updates).
- Significant AI contributions include a model-naming Co-Authored-By trailer and PR disclosure (K22-AGENT-40 / K22-AI-02).
+127
View File
@@ -0,0 +1,127 @@
# Contributing to MarkdownViewr
Thanks for helping. This project follows the
[KofTwentyTwo standards](https://github.com/KofTwentyTwo/standards); this page is the
short version of what they ask of a contribution.
## Before you start
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/MarkdownViewr.Windows/issues).
## Setup
Prerequisites: Windows 10 1809 or later, the .NET 10 SDK (`global.json` pins the feature
band), and optionally Visual Studio 2026 or Rider. The
[workstation setup](https://github.com/KofTwentyTwo/standards#set-up-your-machine)
installs everything, including the git hooks' tools. The commands below are exactly what
CI runs; run them before pushing.
```powershell
dotnet restore MarkdownViewr.Windows.slnx -p:Platform=x64 --locked-mode
dotnet build MarkdownViewr.Windows.slnx -p:Platform=x64 -warnaserror
# Unit tests, then the 100% line-coverage gate over every measured assembly
dotnet test MarkdownViewr.Windows.slnx -p:Platform=x64 --no-build --filter "FullyQualifiedName!~UiTests" --settings coverage.runsettings --collect:"XPlat Code Coverage" --results-directory TestResults
./build/Assert-Coverage.ps1 -ResultsDirectory TestResults
# End-to-end UI tests: FlaUI launches the app (needs an interactive desktop)
dotnet test tests/MarkdownViewr.Windows.UiTests
# Style gate (Kingsrook layout, analyzers, naming)
dotnet format MarkdownViewr.Windows.slnx --verify-no-changes --severity warn --no-restore
# PowerShell tooling gate (install PSScriptAnalyzer 1.25.0 once)
Import-Module PSScriptAnalyzer -RequiredVersion 1.25.0
Invoke-ScriptAnalyzer -Path build -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 -EnableExit
Import-Module Pester -RequiredVersion 5.7.1
Invoke-Pester -Path build/Assert-Coverage.Tests.ps1 -CI
```
`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 MarkdownViewr.Windows.slnx -p:Platform=x64 --force-evaluate`).
Install the git hooks once per clone (the hook configuration comes from
[KofTwentyTwo/standards](https://github.com/KofTwentyTwo/standards/tree/main/configs/pre-commit)):
```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/export` or `fix/42-splash-never-dismisses`.
2. Commit with [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
(`feat: ...`, `fix(settings): ...`). 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 `MarkdownViewr.Windows.Core` stays at **100%**. Logic goes in `MarkdownViewr.Windows.Core`; the
WinUI project holds views and framework plumbing only
([ADR-0001](docs/adr/0001-adopt-the-windows-desktop-reference-architecture.md)).
- 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 |
| --- | --- |
| `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 |
| `ci / format` | `dotnet format --verify-no-changes --severity warn` |
| `ci / ui-tests` | FlaUI end-to-end tests of the app |
| `installed-updates` | Real stable/dev installers, upgrades, restart and published-output UI smoke |
| `powershell`, `javascript` | Tool lint, setup/coverage boundary tests, review tests and dependency signatures |
| `codeql / analyze (javascript-typescript)` | Static analysis of repository review tooling |
| `review / automated` (single maintainer) | Current-commit automated review acknowledged by the configured human maintainer |
Run `npm ci --ignore-scripts`, `npm run lint` and `npm test` for repository JavaScript.
Run PSScriptAnalyzer with `PSScriptAnalyzerSettings.psd1` and Pester against
`build/*.Tests.ps1`. New owners follow [repository setup](docs/repository-setup.md).
## Sign-off (Developer Certificate of Origin)
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.
Include a Co-Authored-By trailer naming the model for significant AI contributions
(K22-AGENT-40 / K22-AI-02).
## License
By contributing, you agree that your contributions are licensed under the project's
[MIT License](LICENSE).
+70
View File
@@ -0,0 +1,70 @@
<Project>
<!-- 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>
<!-- 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>
<!-- Reproducible builds with source-linked symbols. -->
<Deterministic>true</Deterministic>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<!-- 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. Local builds are <prefix>-dev; the release workflow passes
-p:Version=<tag-version>, which overrides both parts. SourceLink appends the git
commit as "+<sha>" on AssemblyInformationalVersion, which the About dialog and the
CLI's version output show as a short hash. -->
<PropertyGroup>
<VersionPrefix>0.1.0</VersionPrefix>
<VersionSuffix>dev</VersionSuffix>
</PropertyGroup>
<!-- Product metadata stamped into every assembly. -->
<PropertyGroup>
<Authors>James Maes</Authors>
<Product>MarkdownViewr</Product>
<Copyright>Copyright (c) 2026 James Maes</Copyright>
<RepositoryUrl>https://github.com/KofTwentyTwo/MarkdownViewr.Windows</RepositoryUrl>
<RepositoryType>git</RepositoryType>
</PropertyGroup>
</Project>
+35
View File
@@ -0,0 +1,35 @@
<Project>
<!-- 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 Label="Analyzers">
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.296" />
</ItemGroup>
<ItemGroup Label="KofTwentyTwo AppKit">
<PackageVersion Include="KofTwentyTwo.AppKit" Version="0.1.0" />
<PackageVersion Include="KofTwentyTwo.AppKit.Updates" Version="0.1.0" />
<PackageVersion Include="KofTwentyTwo.AppKit.WinUI" Version="0.1.0" />
</ItemGroup>
<ItemGroup>
<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>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 James Maes
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+20
View File
@@ -0,0 +1,20 @@
<Solution>
<Configurations>
<Platform Name="ARM64" />
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Folder Name="/src/">
<Project Path="src/MarkdownViewr.Windows/MarkdownViewr.Windows.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
<Platform Solution="*|x86" Project="x86" />
</Project>
<Project Path="src/MarkdownViewr.Windows.Core/MarkdownViewr.Windows.Core.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/MarkdownViewr.Windows.Core.Tests/MarkdownViewr.Windows.Core.Tests.csproj" />
<Project Path="tests/MarkdownViewr.Windows.UiTests/MarkdownViewr.Windows.UiTests.csproj" />
<Project Path="tests/MarkdownViewr.Windows.UpdateProbe/MarkdownViewr.Windows.UpdateProbe.csproj" />
</Folder>
</Solution>
+56
View File
@@ -0,0 +1,56 @@
# KofTwentyTwo PowerShell analysis (K22-CODE-SH-05). CI runs:
# Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 -EnableExit
# and `Invoke-Formatter` with the same settings for layout. PowerShell supports the
# Kingsrook layout fully: next-line (Allman) braces and 3-space indentation.
@{
Severity = @('Error', 'Warning', 'Information')
IncludeDefaultRules = $true
ExcludeRules = @()
Rules = @{
PSPlaceOpenBrace = @{
Enable = $true
OnSameLine = $false
NewLineAfter = $true
IgnoreOneLineBlock = $true
}
PSPlaceCloseBrace = @{
Enable = $true
NewLineAfter = $true
IgnoreOneLineBlock = $true
NoEmptyLineBefore = $true
}
PSUseConsistentIndentation = @{
Enable = $true
IndentationSize = 3
Kind = 'space'
PipelineIndentation = 'IncreaseIndentationForFirstPipeline'
}
PSUseConsistentWhitespace = @{
Enable = $true
CheckInnerBrace = $true
# Kingsrook writes if(...) with no space; the rule cannot require that, so it
# is not checked either way.
CheckOpenParen = $false
CheckOpenBrace = $false
# Kingsrook aligns the `=` of consecutive assignments (PSAlignAssignmentStatement
# below); CheckOperator would then flag every aligned line, so it is off.
CheckOperator = $false
CheckPipe = $true
CheckSeparator = $true
CheckParameter = $true
}
PSAlignAssignmentStatement = @{
Enable = $true
CheckHashtable = $true
}
PSUseCorrectCasing = @{ Enable = $true }
PSAvoidUsingCmdletAliases = @{ Enable = $true }
PSAvoidUsingWriteHost = @{ Enable = $true }
PSProvideCommentHelp = @{
Enable = $true
ExportedOnly = $false
BlockComment = $true
Placement = 'before'
}
}
}
+95
View File
@@ -0,0 +1,95 @@
# MarkdownViewr
> In development: a Windows counterpart to [Darin's markdownViewr](https://github.com/darinkelkhoff/markdownViewr), built with AppKit. The application currently shows the starter's greeting screen; Markdown viewing is not implemented yet. See [the feature plan](docs/PRODUCT.md) and [development tasks](docs/TODO.md). No Windows release is available yet.
A Windows Markdown viewer with live reload, themes and document navigation.
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/KofTwentyTwo/MarkdownViewr.Windows/badge)](https://scorecard.dev/viewer/?uri=github.com/KofTwentyTwo/MarkdownViewr.Windows)
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](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); built from the KofTwentyTwo Windows app template |
| **Latest release** | [Releases](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/latest) |
## Install
Download `markdownviewr-windows-stable-Setup.exe` from the
[latest release](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/releases/latest) and run it. It
installs per user (no administrator rights needed) and updates itself in place from
**Help → Check for updates**. `markdownviewr-windows-stable-Portable.zip` is a portable build that
does not self-update.
Prerelease tags (`v1.2.3-beta.1`) publish to the `dev` channel
(`markdownviewr-windows-dev-Setup.exe`); an installation only ever updates within its own channel.
## Usage
- **Greeting (example feature):** type a name and press **Greet**. The name is remembered
for next time. This is the template's example of a feature: logic and state in
`MarkdownViewr.Windows.Core`, a thin view in `MarkdownViewr.Windows`.
- **View:** choose the light, dark, or system theme; turn the startup splash and the
startup update check on or off. Choices are saved immediately.
- **Help → Activity log:** a live view of the app's log, with an errors-only filter and a
shortcut to the log folder.
- **Help → Check for updates** and **Help → About**.
Settings and logs live in `%LOCALAPPDATA%\markdownviewr-windows`. Set the data-folder environment
variable the app reports in its log (the app id in capitals, plus `_DATA_DIR`) to use a
different folder, for example a portable one.
## Building from source
Prerequisites:
- Windows 10 version 1809 or later, x64 or ARM64
- .NET 10 SDK (the feature band is pinned by `global.json`)
- Optionally Visual Studio 2026 (Windows App SDK workload) or Rider
```powershell
# Once after generation, create and commit the NuGet locks locally:
# dotnet restore MarkdownViewr.Windows.slnx -p:Platform=x64
dotnet restore MarkdownViewr.Windows.slnx -p:Platform=x64 --locked-mode
dotnet build MarkdownViewr.Windows.slnx -p:Platform=x64 -warnaserror
dotnet test MarkdownViewr.Windows.slnx -p:Platform=x64 --no-build --filter "FullyQualifiedName!~UiTests"
dotnet test tests/MarkdownViewr.Windows.UiTests --no-build # launches the app on the desktop
```
Package versions are declared in `Directory.Packages.props` and locked in each project's
`packages.lock.json`. [CONTRIBUTING.md](CONTRIBUTING.md) has the full local workflow,
including the coverage gate and the format check that CI runs.
## Verifying a release
Every release asset is listed in `SHA256SUMS` and has a build-provenance attestation
signed by the KofTwentyTwo release workflow:
```powershell
# Integrity: the file matches the published checksum
(Get-FileHash .\markdownviewr-windows-stable-Setup.exe -Algorithm SHA256).Hash # compare to SHA256SUMS
# Origin: built by the KofTwentyTwo release workflow from this repository
gh attestation verify .\markdownviewr-windows-stable-Setup.exe --repo KofTwentyTwo/MarkdownViewr.Windows --signer-repo KofTwentyTwo/standards
# Publisher: Authenticode signature (once code signing is enabled; see SECURITY.md)
Get-AuthenticodeSignature .\markdownviewr-windows-stable-Setup.exe | Format-List Status, SignerCertificate
```
## Support
The latest minor release receives fixes; see [SECURITY.md](SECURITY.md#supported-versions)
for the full support policy.
## Security
Report vulnerabilities privately; see [SECURITY.md](SECURITY.md). The app's attack
surface is described in its [threat model](docs/security/threat-model.md).
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md).
## License
[MIT](LICENSE). Copyright (c) 2026 James Maes.
+59
View File
@@ -0,0 +1,59 @@
# Security Policy
## Reporting a vulnerability
**Do not open a public issue for a security problem.**
Report it privately through GitHub's
[private vulnerability reporting](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/security/advisories/new)
(the repository's **Security** tab, then **Report a vulnerability**). That is the only
reporting channel.
Security contact: **James Maes**, maintainer (reached through the private reporting link
above).
Please include the affected version, how to reproduce it, and the impact you see. The
app's attack surface is described in its
[threat model](docs/security/threat-model.md).
## What to expect
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):
| 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 | Yes |
| Older | No; update in place with **Help → Check for updates**, or install the latest release |
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.
## Code signing
Windows installers and binaries are not yet Authenticode-signed (KofTwentyTwo standards
exception EX-0002, until the signing identity is validated). Until then, verify downloads
with `SHA256SUMS` and `gh attestation verify` as described in the
[README](README.md#verifying-a-release).
## Published vulnerabilities
Fixed vulnerabilities are published as
[GitHub Security Advisories](https://github.com/KofTwentyTwo/MarkdownViewr.Windows/security/advisories).
+74
View File
@@ -0,0 +1,74 @@
#Requires -Version 7.4
# Copyright (c) 2026 James Maes (KofTwentyTwo)
# SPDX-License-Identifier: MIT
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Continue Pester commands so their Allman scriptblocks stay part of the call.
BeforeAll `
{
$script:CoverageGate = Join-Path $PSScriptRoot 'Assert-Coverage.ps1'
<#
.SYNOPSIS
Writes a synthetic report so tests can exercise release-gate exit codes.
#>
function Write-CoverageReport
{
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $Directory,
[Parameter(Mandatory)] [ValidateRange(0, 1)] [double] $Rate
)
$rateText = $Rate.ToString([Globalization.CultureInfo]::InvariantCulture)
$secondLineHits = if ($Rate -eq 1) { 1 } else { 0 }
$report = @"
<coverage><packages><package name="Probe" line-rate="$rateText"><classes>
<class filename="Probe.cs"><lines><line number="1" hits="1" /><line number="2" hits="$secondLineHits" /></lines></class>
</classes></package></packages></coverage>
"@
Set-Content -LiteralPath (Join-Path $Directory 'coverage.cobertura.xml') -Value $report
}
}
Describe 'Required coverage gate' `
{
BeforeEach `
{
$script:ReportsDirectory = Join-Path $TestDrive ([guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path $script:ReportsDirectory | Out-Null
}
It 'rejects a run that produced no report' `
{
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n"
$LASTEXITCODE | Should -Be 1
$output | Should -Match 'No coverage.cobertura.xml'
}
It 'rejects an absent required package' `
{
Write-CoverageReport -Directory $script:ReportsDirectory -Rate 1
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Missing 2>&1) -join "`n"
$LASTEXITCODE | Should -Be 1
$output | Should -Match 'Missing.*not found'
}
It 'rejects coverage below the required percentage' `
{
Write-CoverageReport -Directory $script:ReportsDirectory -Rate 0.5
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n"
$LASTEXITCODE | Should -Be 1
$output | Should -Match 'Coverage gate FAILED'
}
It 'accepts full coverage of the required package' `
{
Write-CoverageReport -Directory $script:ReportsDirectory -Rate 1
$output = (& pwsh -NoProfile -File $script:CoverageGate -ResultsDirectory $script:ReportsDirectory -Packages Probe 2>&1) -join "`n"
$LASTEXITCODE | Should -Be 0
$output | Should -Match 'Coverage gate passed'
}
}
+86
View File
@@ -0,0 +1,86 @@
#Requires -Version 7.4
<#
.SYNOPSIS
Fails when any named assembly is below the required line coverage.
.DESCRIPTION
Reads every coverage.cobertura.xml under -ResultsDirectory (one per test project
run) and, for each package, takes the best line rate any report measured. A package
can be split across test projects, and the report that exercises it most is
authoritative. Without -Packages, every package the reports measured is gated:
coverage.runsettings already limits measurement to the assemblies held to the bar.
Exits 1 on any shortfall, or when a package named in -Packages is missing.
.EXAMPLE
./build/Assert-Coverage.ps1 -ResultsDirectory TestResults
.EXAMPLE
./build/Assert-Coverage.ps1 -ResultsDirectory TestResults -Packages MarkdownViewr.Windows.Core
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $ResultsDirectory,
[string[]] $Packages = @(),
[ValidateRange(0, 100)] [double] $MinimumPercent = 100
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# `pwsh -File` passes "A,B" as one string; accept that as well as a real array.
$Packages = @($Packages | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ })
$files = @(Get-ChildItem -Path $ResultsDirectory -Recurse -Filter coverage.cobertura.xml -ErrorAction SilentlyContinue)
if ($files.Count -eq 0)
{
Write-Error "No coverage.cobertura.xml found under '$ResultsDirectory'. Did the coverage collector run?" -ErrorAction Continue
exit 1
}
$reports = foreach ($file in $files) { [xml](Get-Content -LiteralPath $file.FullName -Raw) }
if ($Packages.Count -eq 0)
{
$Packages = @($reports | ForEach-Object { @($_.coverage.packages.package) } | ForEach-Object { $_.name } | Where-Object { $_ } | Sort-Object -Unique)
if ($Packages.Count -eq 0)
{
Write-Error 'The coverage reports measured no packages; check the Include filter in coverage.runsettings.' -ErrorAction Continue
exit 1
}
}
$required = $MinimumPercent / 100
$failed = $false
foreach ($name in $Packages)
{
$rates = foreach ($doc in $reports)
{
$package = @($doc.coverage.packages.package) | Where-Object { $_.name -eq $name }
if ($package) { [double]::Parse($package.'line-rate', [Globalization.CultureInfo]::InvariantCulture) }
}
if (-not $rates)
{
Write-Error "Package '$name' not found in any coverage report." -ErrorAction Continue
$failed = $true
continue
}
$best = ($rates | Measure-Object -Maximum).Maximum
Write-Output ("{0} line coverage: {1}% (required {2}%)" -f $name, [math]::Round($best * 100, 2), $MinimumPercent)
if ($best -lt $required)
{
foreach ($doc in $reports)
{
$package = @($doc.coverage.packages.package) | Where-Object { $_.name -eq $name }
foreach ($class in @($package.classes.class))
{
$missed = @($class.lines.line) | Where-Object { $_.hits -eq '0' } | ForEach-Object { $_.number }
if ($missed) { Write-Output " uncovered: $($class.filename) lines $($missed -join ', ')" }
}
}
Write-Error "Coverage gate FAILED: $name is below $MinimumPercent%." -ErrorAction Continue
$failed = $true
}
}
if ($failed) { exit 1 }
Write-Output "Coverage gate passed."
+59
View File
@@ -0,0 +1,59 @@
#Requires -Version 7.4
# Copyright (c) 2026 James Maes
# SPDX-License-Identifier: MIT
<#
.SYNOPSIS
Verifies the published runtime payload is preserved in Velopack portable and full packages.
.DESCRIPTION
Compares every non-PDB publish file except Velopack's documented default exclusions.
Checks original files, not generated launchers or manifest additions; does not verify signatures.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$PublishDirectory,
[Parameter(Mandatory)] [string]$PortableArchive,
[Parameter(Mandatory)] [string]$FullPackage
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$source = (Get-Item -LiteralPath $PublishDirectory).FullName
$files = @(Get-ChildItem -LiteralPath $source -File -Recurse | Where-Object {
$_.Extension -ne '.pdb' -and $_.FullName -notmatch '[\\/]createdump[^\\/]*|\.vshost\.|\.nupkg$'
})
if($files.Count -eq 0) { throw 'Publish payload is empty.' }
foreach($file in $files | Where-Object Extension -In '.exe', '.dll')
{
$stream = [IO.File]::OpenRead($file.FullName)
try
{
$reader = [Reflection.PortableExecutable.PEReader]::new($stream)
try
{
if($null -eq $reader.PEHeaders.PEHeader) { throw 'Missing executable header.' }
}
finally { $reader.Dispose() }
}
catch { throw "Invalid published PE binary: $($file.Name). $($_.Exception.Message)" }
finally { $stream.Dispose() }
}
foreach($specification in @(@{ path = $PortableArchive; prefix = 'current/' }, @{ path = $FullPackage; prefix = 'lib/app/' }))
{
$archive = [IO.Compression.ZipFile]::OpenRead((Get-Item -LiteralPath $specification.path).FullName)
try
{
foreach($file in $files)
{
$relative = [IO.Path]::GetRelativePath($source, $file.FullName).Replace('\', '/')
$entry = $archive.GetEntry($specification.prefix + $relative)
if($null -eq $entry) { throw "Package omits published file: $relative ($($specification.path))" }
$stream = $entry.Open()
try { $actual = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($stream)) }
finally { $stream.Dispose() }
$expected = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash
if($actual -ne $expected) { throw "Package changed published bytes: $relative ($($specification.path))" }
}
}
finally { $archive.Dispose() }
}
Write-Output "Verified $($files.Count) published files in both Velopack payloads."
+43
View File
@@ -0,0 +1,43 @@
#Requires -Version 7.4
# Copyright (c) 2026 James Maes
# SPDX-License-Identifier: MIT
<#
.SYNOPSIS
Rejects application release publication without explicit signing and required configuration.
.DESCRIPTION
Checks the reviewed caller and protected-environment variables before running release gates.
This is configuration validation, not proof of Azure access or a valid signature.
#>
[CmdletBinding()]
param(
[string]$WorkflowPath = (Join-Path $PSScriptRoot '../.github/workflows/release.yml')
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$workflow = Get-Content -LiteralPath $WorkflowPath -Raw
if($workflow -notmatch '(?m)^[ \t]+sign:[ \t]+true(?:[ \t]+#.*)?[ \t]*\r?$')
{
throw 'Release signing is required. Publish and pin the reviewed signing builder, configure sign: true and its owned-file catalog. See docs/release-signing.md.'
}
if($workflow -notmatch '(?m)^([ \t]+)signing-files:[ \t]*\|[ \t]*\r?\n\1[ \t]+[^#\s]')
{
throw 'Release signing requires an explicit signing-files catalog. See docs/release-signing.md.'
}
$guidVariables = @('AZURE_CLIENT_ID', 'AZURE_TENANT_ID', 'AZURE_SUBSCRIPTION_ID')
foreach($name in @($guidVariables) + @('ARTIFACT_SIGNING_ENDPOINT', 'ARTIFACT_SIGNING_ACCOUNT', 'ARTIFACT_SIGNING_PROFILE', 'ARTIFACT_SIGNING_SUBJECT'))
{
$value = [Environment]::GetEnvironmentVariable($name)
if([string]::IsNullOrWhiteSpace($value)) { throw "Missing verified signing variable: $name" }
if($name -in $guidVariables)
{
$identifier = [guid]::Empty
if(-not [guid]::TryParseExact($value, 'D', [ref]$identifier) -or $identifier -eq [guid]::Empty) { throw "Invalid signing identifier: $name" }
}
}
$endpoint = $null
if(-not [uri]::TryCreate($env:ARTIFACT_SIGNING_ENDPOINT, [UriKind]::Absolute, [ref]$endpoint) -or $endpoint.Scheme -ne 'https' -or -not $endpoint.Host.EndsWith('.codesigning.azure.net', [StringComparison]::OrdinalIgnoreCase))
{
throw 'The verified Artifact Signing endpoint must use HTTPS on codesigning.azure.net.'
}
Write-Output 'Windows release signing configuration is present; Azure access and final signatures still require verification.'
+113
View File
@@ -0,0 +1,113 @@
#Requires -Version 7.4
# Copyright (c) 2026 James Maes (KofTwentyTwo)
# SPDX-License-Identifier: MIT
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
BeforeAll `
{
$script:Generator = Join-Path $PSScriptRoot 'New-DistributionManifests.ps1'
<#
.SYNOPSIS
Registers the package-manager API for a test double without network or installs.
#>
function Install-ChocolateyZipPackage
{
param($PackageName, $UnzipLocation, $Url64bit, $Checksum64, $ChecksumType64)
throw "Missing Chocolatey test double: $PackageName at $UnzipLocation from $Url64bit ($ChecksumType64 $Checksum64)."
}
}
Describe 'Optional distribution boundaries' `
{
BeforeEach `
{
$script:Root = Join-Path $TestDrive ([guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $script:Root | Out-Null
$script:Output = Join-Path $script:Root 'output'
$script:Artifact = Join-Path $script:Root 'notes-cli-win-x64-1.2.3.zip'
$archive = [IO.Compression.ZipFile]::Open($script:Artifact, [IO.Compression.ZipArchiveMode]::Create)
try
{
$entry = $archive.CreateEntry('notes.exe')
$writer = [IO.StreamWriter]::new($entry.Open())
$writer.Write('fixture bytes, never executed')
$writer.Dispose()
}
finally { $archive.Dispose() }
}
It 'uses the actual final bytes, generated identities and stable release URL' `
{
& $script:Generator -Version 1.2.3 -ArtifactPath $script:Artifact -ArtifactKind Cli -AppId notes -Repository 'Example/Notes' -DisplayName 'Notes & More' -OutputDirectory $script:Output
$manifest = Get-Content "$script:Output/bucket/notes-cli.json" -Raw | ConvertFrom-Json
$manifest.architecture.'64bit'.hash | Should -Be (Get-FileHash $script:Artifact -Algorithm SHA256).Hash.ToLowerInvariant()
$manifest.architecture.'64bit'.url | Should -Be 'https://github.com/Example/Notes/releases/download/v1.2.3/notes-cli-win-x64-1.2.3.zip'
$manifest.bin | Should -Be 'notes.exe'
$spec = [xml](Get-Content "$script:Output/chocolatey/notes-cli/notes-cli.nuspec" -Raw)
$spec.package.metadata.title | Should -Be 'Notes & More'
$spec.package.metadata.id | Should -Be 'notes-cli'
Test-Path "$script:Output/chocolatey/notes-cli/tools/LICENSE.txt" | Should -BeTrue
}
It 'refuses prerelease versions before writing any manifests' `
{
{ & $script:Generator -Version '1.2.3-beta.1' -ArtifactPath $script:Artifact -OutputDirectory $script:Output } | Should -Throw
Test-Path $script:Output | Should -BeFalse
}
It 'refuses mismatched version or app identity' `
{
{ & $script:Generator -Version 1.2.4 -ArtifactPath $script:Artifact -ArtifactKind Cli -AppId notes -OutputDirectory $script:Output } | Should -Throw '*does not match*'
Test-Path $script:Output | Should -BeFalse
}
It 'rejects traversal inside an archive before writing' `
{
$archive = [IO.Compression.ZipFile]::Open($script:Artifact, [IO.Compression.ZipArchiveMode]::Update)
try { $null = $archive.CreateEntry('../outside.exe') } finally { $archive.Dispose() }
{ & $script:Generator -Version 1.2.3 -ArtifactPath $script:Artifact -ArtifactKind Cli -AppId notes -OutputDirectory $script:Output } | Should -Throw '*unsafe path*'
Test-Path $script:Output | Should -BeFalse
}
It 'does not write during WhatIf' `
{
& $script:Generator -Version 1.2.3 -ArtifactPath $script:Artifact -ArtifactKind Cli -AppId notes -OutputDirectory $script:Output -WhatIf
Test-Path $script:Output | Should -BeFalse
}
It 'supports GUI-only portable archives without requiring a CLI' `
{
$portable = Join-Path $script:Root 'notes-stable-Portable.zip'
$archive = [IO.Compression.ZipFile]::Open($portable, [IO.Compression.ZipArchiveMode]::Create)
try { $null = $archive.CreateEntry('Notes.exe') } finally { $archive.Dispose() }
& $script:Generator -Version 1.2.3 -ArtifactPath $portable -ArtifactKind Portable -AppId notes -MainExe Notes.exe -DisplayName 'Notes & More' -OutputDirectory $script:Output
$manifest = Get-Content "$script:Output/bucket/notes-portable.json" -Raw | ConvertFrom-Json
$manifest.shortcuts[0][0] | Should -Be 'Notes.exe'
$manifest.shortcuts[0][1] | Should -Be 'Notes & More'
$manifest.PSObject.Properties.Name | Should -Not -Contain 'bin'
}
It 'creates a shim only for the selected executable and passes the verified checksum' `
{
$archive = [IO.Compression.ZipFile]::Open($script:Artifact, [IO.Compression.ZipArchiveMode]::Update)
try { $null = $archive.CreateEntry('helpers/Update.exe') } finally { $archive.Dispose() }
& $script:Generator -Version 1.2.3 -ArtifactPath $script:Artifact -ArtifactKind Cli -AppId notes -OutputDirectory $script:Output
$fixtureArchive = $script:Artifact
Mock Install-ChocolateyZipPackage -MockWith (
{
param($PackageName, $UnzipLocation, $Url64bit, $Checksum64, $ChecksumType64)
$PackageName | Should -Be 'notes-cli'
$Url64bit | Should -Match '/releases/download/v1.2.3/'
$ChecksumType64 | Should -Be 'sha256'
$Checksum64 | Should -Be (Get-FileHash $fixtureArchive -Algorithm SHA256).Hash.ToLowerInvariant()
[IO.Compression.ZipFile]::ExtractToDirectory($fixtureArchive, $UnzipLocation)
}.GetNewClosure())
$toolsDirectory = "$script:Output/chocolatey/notes-cli/tools"
& "$toolsDirectory/chocolateyinstall.ps1"
Test-Path "$toolsDirectory/app/notes.exe.ignore" | Should -BeFalse
Test-Path "$toolsDirectory/app/helpers/Update.exe.ignore" | Should -BeTrue
Should -Invoke Install-ChocolateyZipPackage -Times 1 -Exactly
}
}
+252
View File
@@ -0,0 +1,252 @@
#Requires -Version 7.4
# Copyright (c) 2026 James Maes (KofTwentyTwo)
# SPDX-License-Identifier: MIT
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot 'GitHubRepository.psm1') -Force
Describe 'Offline setup preview location' `
{
It 'writes relative output beneath the current PowerShell location' `
{
$setupScript = Join-Path $PSScriptRoot 'Initialize-GitHubRepository.ps1'
Push-Location $TestDrive
try
{
& $setupScript -Repository 'Example/Notes'
$plan = Get-Content -LiteralPath (Join-Path $TestDrive 'artifacts/repository-setup.json') -Raw | ConvertFrom-Json
$plan.Repository | Should -Be 'Example/Notes'
$plan.Requests.Count | Should -Be 13
}
finally
{
Pop-Location
}
}
}
Describe 'Required checks match standalone workflow jobs' `
{
It 'requires the actual check names from both CI and script workflows' `
{
$plan = Get-GitHubRepositoryPlan -Repository 'Example/Notes'
$workflowRoot = Join-Path (Split-Path -Parent $PSScriptRoot) '.github/workflows'
foreach($workflow in @('ci.yml', 'scripts.yml'))
{
$names = @(Select-String -LiteralPath (Join-Path $workflowRoot $workflow) -Pattern '^ name: ')
$names.Count | Should -BeGreaterThan 0
foreach($name in $names)
{
$plan.Contexts | Should -Contain $name.Line.Trim().Substring('name: '.Length)
}
}
}
}
Describe 'Generated repository administration' `
{
InModuleScope GitHubRepository `
{
BeforeEach `
{
$script:Plan = Get-GitHubRepositoryPlan -Repository 'Example/Notes'
$script:Root = 'repos/Example/Notes'
$script:Writes = [System.Collections.Generic.List[string]]::new()
$script:State = @{
'apps/github-actions' = @{ id = 15368; slug = 'github-actions'; owner = @{ login = 'github' } }
$script:Root = @{ full_name = 'Example/Notes'; private = $false; default_branch = 'main'; permissions = @{ admin = $true } }
"$script:Root/rulesets?includes_parents=true&per_page=100" = @()
"$script:Root/environments?per_page=100" = @{ environments = @() }
}
$script:Checks = @($script:Plan.Contexts | ForEach-Object {
@{ name = $_; conclusion = 'success'; started_at = '2026-01-01'; app = @{ slug = 'github-actions'; id = 15368 } }
})
$script:State["$script:Root/branches/main"] = @{ name = 'main' }
$script:State["$script:Root/actions/secrets?per_page=100"] = @{ total_count = 0; secrets = @() }
$script:State["$script:Root/actions/runners?per_page=100"] = @{ total_count = 0; runners = @() }
$script:State["$script:Root/pulls?state=open&base=main&per_page=100"] = @(@{ head = @{ sha = ('a' * 40) } })
$script:State["$script:Root/code-scanning/analyses?per_page=1"] = @(@{ id = 1 })
Mock Invoke-RepositoryApi `
{
if(-not $Method) { $Method = 'GET' }
if($Path -match '/commits/.+/check-runs') { return @{ check_runs = $script:Checks } }
if($Method -eq 'GET')
{
if(-not $script:State.ContainsKey($Path)) { throw "Fixture has no response: $Path" }
return $script:State[$Path]
}
$script:Writes.Add("$Method $Path")
if($Path.EndsWith('/rulesets'))
{
$id = 100 + $script:Writes.Count
$script:State["$script:Root/rulesets?includes_parents=true&per_page=100"] += @(@{ name = $Body.name; id = $id })
$script:State["$script:Root/rulesets/$id"] = $Body
}
elseif($Path.EndsWith('/deployment-branch-policies'))
{
$script:State["$Path`?per_page=100"] = @{ branch_policies = @($Body) }
}
elseif($Path.EndsWith('/environments/release'))
{
$script:State[$Path] = $Body
$script:State["$script:Root/environments?per_page=100"] = @{ environments = @(@{ name = 'release'; deployment_branch_policy = $Body.deployment_branch_policy }) }
}
elseif($Path -eq $script:Root)
{
foreach($key in $Body.Keys) { $script:State[$Path][$key] = $Body[$key] }
}
elseif($Path.EndsWith('/private-vulnerability-reporting')) { $script:State[$Path] = @{ enabled = $true } }
elseif($Path.EndsWith('/automated-security-fixes')) { $script:State[$Path] = @{ enabled = $true; paused = $false } }
elseif($Path.EndsWith('/immutable-releases')) { $script:State[$Path] = @{ enabled = $true } }
else { $script:State[$Path] = $Body }
return @{}
}
}
It 'builds a single-maintainer plan with review, scoped tags and no main bypass' `
{
$script:Plan.Contexts | Should -Contain 'review / automated'
$script:Plan.Requests[-2].Body.bypass_actors.Count | Should -Be 0
$script:Plan.Requests[-2].Body.rules[4].parameters.required_approving_review_count | Should -Be 0
$script:Plan.Requests[-3].Body.type | Should -Be 'tag'
$script:Writes.Count | Should -Be 0
}
It 'requires independent approval when more than one maintainer exists' `
{
$plan = Get-GitHubRepositoryPlan -Repository 'Example/Notes' -MaintainerCount 2
$plan.Contexts | Should -Not -Contain 'review / automated'
$plan.Requests[-2].Body.rules[4].parameters.required_approving_review_count | Should -Be 1
$plan.Requests[-2].Body.rules[4].parameters.require_last_push_approval | Should -BeTrue
}
It 'applies and reads back all settings without recreating policies on a second run' `
{
$result = Invoke-GitHubRepositorySetup -Plan $script:Plan -Mode Apply -CheckSha ('a' * 40) -Confirm:$false
$result.SettingsVerified | Should -BeTrue
$script:Writes.Count | Should -Be 13
$main = $script:State["$script:Root/rulesets?includes_parents=true&per_page=100"] | Where-Object name -EQ 'protect-main'
$script:State["$script:Root/rulesets/$($main.id)"].rules[-1].parameters.required_status_checks[0].integration_id | Should -Be 15368
$script:Writes.Clear()
$newPlan = Get-GitHubRepositoryPlan -Repository 'Example/Notes'
Invoke-GitHubRepositorySetup -Plan $newPlan -Mode Apply -CheckSha ('a' * 40) -Confirm:$false
@($script:Writes | Where-Object { $_ -match 'POST|environments/release$' }).Count | Should -Be 0
}
It 'previews all mutations without writing' `
{
Invoke-GitHubRepositorySetup -Plan $script:Plan -Mode Apply -CheckSha ('a' * 40) -WhatIf
$script:Writes.Count | Should -Be 0
}
It 'rejects <Binding> required-check application binding during read-only validation' -ForEach @(
@{ Binding = 'incorrect' }, @{ Binding = 'missing' }
) `
{
Invoke-GitHubRepositorySetup -Plan $script:Plan -Mode Apply -CheckSha ('a' * 40) -Confirm:$false
$main = $script:State["$script:Root/rulesets?includes_parents=true&per_page=100"] | Where-Object name -EQ 'protect-main'
$check = $script:State["$script:Root/rulesets/$($main.id)"].rules[-1].parameters.required_status_checks[0]
if($Binding -eq 'missing') { $check.Remove('integration_id') } else { $check.integration_id = 777 }
$script:Writes.Clear()
$plan = Get-GitHubRepositoryPlan -Repository 'Example/Notes'
{ Invoke-GitHubRepositorySetup -Plan $plan -Mode Validate } | Should -Throw '*protect-main*'
$script:Writes.Count | Should -Be 0
}
It 'preserves the caller plan and validates correct bindings without writing' `
{
$original = $script:Plan | ConvertTo-Json -Depth 30 -Compress
Invoke-GitHubRepositorySetup -Plan $script:Plan -Mode Apply -CheckSha ('a' * 40) -Confirm:$false
($script:Plan | ConvertTo-Json -Depth 30 -Compress) | Should -Be $original
$script:Writes.Clear()
$result = Invoke-GitHubRepositorySetup -Plan $script:Plan -Mode Validate
$result.SettingsVerified | Should -BeTrue
$script:Writes.Count | Should -Be 0
}
It 'rejects an invalid GitHub Actions application identity: <Invalid>' -ForEach @(
@{ Invalid = 'owner' }, @{ Invalid = 'slug' }, @{ Invalid = 'missing id' }, @{ Invalid = 'zero id' }
) `
{
$app = $script:State['apps/github-actions']
switch($Invalid)
{
'owner' { $app.owner.login = 'untrusted' }
'slug' { $app.slug = 'other-actions' }
'missing id' { $app.Remove('id') }
'zero id' { $app.id = 0 }
}
{ Invoke-GitHubRepositorySetup -Plan $script:Plan -Mode Apply -CheckSha ('a' * 40) } | Should -Throw '*trusted GitHub Actions application identity*'
$script:Writes.Count | Should -Be 0
}
It 'rejects a bootstrap check from a different application before writing' `
{
$script:Checks[0].app.id = 777
{ Invoke-GitHubRepositorySetup -Plan $script:Plan -Mode Apply -CheckSha ('a' * 40) } | Should -Throw '*Bootstrap check not successful*'
$script:Writes.Count | Should -Be 0
}
It 'rejects a changed target repository before writing' `
{
$script:State[$script:Root].full_name = 'Other/Notes'
{ Invoke-GitHubRepositorySetup -Plan $script:Plan -Mode Apply -CheckSha ('a' * 40) } | Should -Throw '*exact existing public repository*'
$script:Writes.Count | Should -Be 0
}
It 'rejects plan changes including unexpected nested settings' `
{
$script:Plan.Requests[0].Body.private = $true
{ Invoke-GitHubRepositorySetup -Plan $script:Plan -Mode Apply -CheckSha ('a' * 40) } | Should -Throw '*differs*'
$script:Writes.Count | Should -Be 0
}
It 'requires successful bootstrap checks before writing' `
{
$script:Checks[-1].conclusion = 'failure'
{ Invoke-GitHubRepositorySetup -Plan $script:Plan -Mode Apply -CheckSha ('a' * 40) } | Should -Throw '*review / automated*'
$script:Writes.Count | Should -Be 0
}
It 'rejects existing conflicting main protection before writing' `
{
$script:State["$script:Root/rulesets?includes_parents=true&per_page=100"] = @(@{ name = 'protect-main'; id = 1 })
$script:State["$script:Root/rulesets/1"] = @{ name = 'protect-main'; enforcement = 'disabled' }
{ Invoke-GitHubRepositorySetup -Plan $script:Plan -Mode Apply -CheckSha ('a' * 40) } | Should -Throw '*will be replaced*'
$script:Writes.Count | Should -Be 0
}
It 'preserves a release environment that allows branch deployments' `
{
$script:State["$script:Root/environments?per_page=100"] = @{ environments = @(@{ name = 'release'; deployment_branch_policy = @{ protected_branches = $true; custom_branch_policies = $false } }) }
{ Invoke-GitHubRepositorySetup -Plan $script:Plan -Mode Apply -CheckSha ('a' * 40) } | Should -Throw '*not custom tag restricted*'
$script:Writes.Count | Should -Be 0
}
It 'rejects a stale bootstrap PR commit before writing' `
{
$script:State["$script:Root/pulls?state=open&base=main&per_page=100"][0].head.sha = 'b' * 40
{ Invoke-GitHubRepositorySetup -Plan $script:Plan -Mode Apply -CheckSha ('a' * 40) } | Should -Throw '*current head*'
$script:Writes.Count | Should -Be 0
}
It 'rejects repository secrets before changing policy' `
{
$script:State["$script:Root/actions/secrets?per_page=100"].total_count = 1
{ Invoke-GitHubRepositorySetup -Plan $script:Plan -Mode Apply -CheckSha ('a' * 40) } | Should -Throw '*repository-level secrets*'
$script:Writes.Count | Should -Be 0
}
It 'reports missing settings in validation without changing them' `
{
foreach($request in $script:Plan.Requests | Where-Object Method -NE 'POST')
{
if($request.Path -ne $script:Root) { $script:State[$request.Path] = @{} }
}
{ Invoke-GitHubRepositorySetup -Plan $script:Plan -Mode Validate } | Should -Throw '*Missing unique protect-main*'
$script:Writes.Count | Should -Be 0
}
}
}
+298
View File
@@ -0,0 +1,298 @@
#Requires -Version 7.4
# Copyright (c) 2026 James Maes (KofTwentyTwo)
# SPDX-License-Identifier: MIT
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
<#
.SYNOPSIS
Builds a non-secret, offline configuration plan for a generated public repository.
#>
function Get-GitHubRepositoryPlan
{
[CmdletBinding()]
[OutputType([hashtable])]
param(
[Parameter(Mandatory)] [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9_.-]+$')] [string] $Repository,
[ValidateRange(1, 100)] [int] $MaintainerCount = 1
)
$root = "repos/$Repository"
$contexts = @('pr / title', 'pr / dco', 'pr / dependency-review', 'security / secrets',
'security / sca', 'security / workflows', 'codeql / analyze (csharp)',
'codeql / analyze (actions)', 'codeql / analyze (javascript-typescript)',
'ci / build-test', 'ci / format', 'ci / ui-tests', 'installed-updates', 'powershell', 'javascript')
if(Test-Path -LiteralPath (Join-Path (Split-Path -Parent $PSScriptRoot) 'templates/KofTwentyTwo.Templates.WindowsApp.csproj'))
{
$contexts += 'template'
}
if($MaintainerCount -eq 1)
{
$contexts += 'review / automated'
}
$main = @{
name = 'protect-main'; target = 'branch'; enforcement = 'active'; bypass_actors = @()
conditions = @{ ref_name = @{ include = @('refs/heads/main'); exclude = @() } }
rules = @(
@{ type = 'deletion' }, @{ type = 'non_fast_forward' }, @{ type = 'required_linear_history' },
@{ type = 'required_signatures' },
@{ type = 'pull_request'; parameters = @{
required_approving_review_count = [int]($MaintainerCount -gt 1)
dismiss_stale_reviews_on_push = $true; require_code_owner_review = $false
require_last_push_approval = ($MaintainerCount -gt 1); required_review_thread_resolution = $true
}
},
@{ type = 'required_status_checks'; parameters = @{
strict_required_status_checks_policy = $true
required_status_checks = @($contexts | ForEach-Object { @{ context = $_ } })
}
}
)
}
$tags = @{
name = 'protect-release-tags'; target = 'tag'; enforcement = 'active'
bypass_actors = @(@{ actor_id = 5; actor_type = 'RepositoryRole'; bypass_mode = 'always' })
conditions = @{ ref_name = @{ include = @('refs/tags/v*'); exclude = @() } }
rules = @(@{ type = 'creation' }, @{ type = 'deletion' }, @{ type = 'update' }, @{ type = 'non_fast_forward' })
}
$requests = @(
@{ Method = 'PATCH'; Path = $root; Body = @{
allow_squash_merge = $true; allow_merge_commit = $false; allow_rebase_merge = $false
squash_merge_commit_title = 'PR_TITLE'; squash_merge_commit_message = 'PR_BODY'
delete_branch_on_merge = $true; has_issues = $true; has_wiki = $false
homepage = "https://github.com/$Repository#readme"
security_and_analysis = @{
secret_scanning = @{ status = 'enabled' }; secret_scanning_push_protection = @{ status = 'enabled' }
}
}
},
@{ Method = 'PUT'; Path = "$root/private-vulnerability-reporting"; Body = @{}; Expected = @{ enabled = $true } },
@{ Method = 'PUT'; Path = "$root/vulnerability-alerts"; Body = @{}; Expected = @{} },
@{ Method = 'PUT'; Path = "$root/automated-security-fixes"; Body = @{}; Expected = @{ enabled = $true; paused = $false } },
@{ Method = 'PUT'; Path = "$root/immutable-releases"; Body = @{}; Expected = @{ enabled = $true } },
@{ Method = 'PUT'; Path = "$root/actions/permissions/workflow"; Body = @{ default_workflow_permissions = 'read'; can_approve_pull_request_reviews = $false } },
@{ Method = 'PUT'; Path = "$root/actions/permissions/fork-pr-contributor-approval"; Body = @{ approval_policy = 'all_external_contributors' } },
@{ Method = 'PUT'; Path = "$root/actions/permissions"; Body = @{ enabled = $true; allowed_actions = 'selected' } },
@{ Method = 'PUT'; Path = "$root/actions/permissions/selected-actions"; Body = @{ github_owned_allowed = $true; verified_allowed = $true; patterns_allowed = @('KofTwentyTwo/standards/*@*') } },
@{ Method = 'PUT'; Path = "$root/environments/release"; Body = @{ deployment_branch_policy = @{ protected_branches = $false; custom_branch_policies = $true } } },
@{ Method = 'POST'; Path = "$root/environments/release/deployment-branch-policies"; Body = @{ name = 'v*'; type = 'tag' } },
@{ Method = 'POST'; Path = "$root/rulesets"; Body = $main },
@{ Method = 'POST'; Path = "$root/rulesets"; Body = $tags }
)
return @{ SchemaVersion = 1; Repository = $Repository; MaintainerCount = $MaintainerCount; Contexts = $contexts; Requests = $requests }
}
<#
.SYNOPSIS
Calls GitHub without printing authentication or including a token in argv.
#>
function Invoke-RepositoryApi
{
[CmdletBinding()]
[OutputType([hashtable], [System.Collections.IDictionary])]
param([Parameter(Mandatory)] [string] $Path, [string] $Method = 'GET', [hashtable] $Body)
$arguments = @('api', $Path, '--method', $Method, '-H', 'X-GitHub-Api-Version: 2022-11-28')
if($null -ne $Body -and $Body.Count -gt 0)
{
$output = $Body | ConvertTo-Json -Depth 30 -Compress | & gh @arguments --input -
}
else
{
$output = & gh @arguments
}
if($LASTEXITCODE -ne 0)
{
throw "GitHub request failed: $Method $Path. No successful configuration is implied."
}
if($output)
{
return ($output -join "`n" | ConvertFrom-Json -AsHashtable)
}
return @{}
}
<#
.SYNOPSIS
Compares intended values while allowing unrelated object properties to remain.
#>
function Test-RepositoryValue
{
[CmdletBinding()]
[OutputType([bool])]
param([AllowNull()] $Actual, [AllowNull()] $Expected)
if($Expected -is [System.Collections.IDictionary])
{
if($Actual -isnot [System.Collections.IDictionary]) { return $false }
foreach($key in $Expected.Keys)
{
if(-not $Actual.Contains($key) -or -not (Test-RepositoryValue $Actual[$key] $Expected[$key])) { return $false }
}
return $true
}
if($Expected -is [array])
{
if(@($Actual).Count -ne $Expected.Count) { return $false }
foreach($item in $Expected)
{
if(-not (@($Actual | Where-Object { Test-RepositoryValue $_ $item }).Count -eq 1)) { return $false }
}
return $true
}
return $Actual -ceq $Expected
}
<#
.SYNOPSIS
Checks all setup prerequisites and preserves existing policy instead of replacing it.
#>
function Invoke-GitHubRepositorySetup
{
[CmdletBinding(SupportsShouldProcess)]
[OutputType([hashtable])]
param(
[Parameter(Mandatory)] [hashtable] $Plan,
[ValidateSet('Validate', 'Apply')] [string] $Mode = 'Validate',
[ValidatePattern('^[a-f0-9]{40}$')] [string] $CheckSha
)
# Never accept an arbitrary JSON request file as an administration program.
$canonical = Get-GitHubRepositoryPlan -Repository $Plan.Repository -MaintainerCount $Plan.MaintainerCount
if(-not (Test-RepositoryValue $Plan $canonical) -or -not (Test-RepositoryValue $canonical $Plan)) { throw 'Plan differs from the generated policy. Regenerate it.' }
# Bind a fresh policy copy; the caller's reviewed offline plan must remain reusable.
$Plan = $canonical
$root = "repos/$($Plan.Repository)"
$repositoryState = Invoke-RepositoryApi $root
if($repositoryState.full_name -ine $Plan.Repository -or $repositoryState.private -or $repositoryState.default_branch -ne 'main')
{
throw 'Target must be the exact existing public repository with main already pushed.'
}
if(-not $repositoryState.permissions.admin) { throw 'Repository administration permission is required.' }
$null = Invoke-RepositoryApi "$root/branches/main"
$actionsApp = Invoke-RepositoryApi 'apps/github-actions'
if($actionsApp -isnot [System.Collections.IDictionary] -or -not $actionsApp.Contains('slug') -or
$actionsApp.slug -cne 'github-actions' -or -not $actionsApp.Contains('owner') -or
$actionsApp.owner -isnot [System.Collections.IDictionary] -or -not $actionsApp.owner.Contains('login') -or
$actionsApp.owner.login -cne 'github' -or -not $actionsApp.Contains('id') -or
($actionsApp.id -isnot [int] -and $actionsApp.id -isnot [long]) -or $actionsApp.id -le 0)
{
throw 'The trusted GitHub Actions application identity could not be verified.'
}
$checkRule = $Plan.Requests[-2].Body.rules | Where-Object type -EQ 'required_status_checks'
foreach($check in $checkRule.parameters.required_status_checks) { $check.integration_id = $actionsApp.id }
$repositorySecrets = Invoke-RepositoryApi "$root/actions/secrets?per_page=100"
if($repositorySecrets.total_count -ne 0) { throw 'Move repository-level secrets to protected environments or OIDC before setup.' }
$runners = Invoke-RepositoryApi "$root/actions/runners?per_page=100"
if($runners.total_count -ne 0) { throw 'Remove self-hosted runner registrations before public repository setup.' }
$rulesets = @(Invoke-RepositoryApi "$root/rulesets?includes_parents=true&per_page=100")
if($rulesets.Count -ge 100) { throw 'Ruleset listing exceeds the supported setup scope. Review separately.' }
$environments = Invoke-RepositoryApi "$root/environments?per_page=100"
$releaseEnvironment = @($environments.environments | Where-Object name -EQ 'release')
$policies = @()
if($releaseEnvironment.Count -gt 0)
{
$policy = $releaseEnvironment[0].deployment_branch_policy
if(-not $policy -or -not $policy.custom_branch_policies -or $policy.protected_branches)
{
throw 'Existing release environment is not custom tag restricted. Review it separately; no settings were changed.'
}
$policyResponse = Invoke-RepositoryApi "$root/environments/release/deployment-branch-policies?per_page=100"
$policies = @($policyResponse.branch_policies)
if(@($policies | Where-Object { $_.type -ne 'tag' -or $_.name -ne 'v*' }).Count -gt 0)
{
throw 'Existing release environment allows other refs. Preserve and review it separately.'
}
}
$operations = [System.Collections.Generic.List[hashtable]]::new()
foreach($request in $Plan.Requests)
{
$operation = @{} + $request
if($request.Path -eq "$root/rulesets")
{
$existing = @($rulesets | Where-Object name -EQ $request.Body.name)
if($existing.Count -gt 1) { throw "Multiple rulesets named $($request.Body.name)." }
if($existing.Count -eq 1)
{
$detail = Invoke-RepositoryApi "$root/rulesets/$($existing[0].id)"
if(-not (Test-RepositoryValue $detail $request.Body)) { throw "Existing $($request.Body.name) differs; no ruleset will be replaced." }
continue
}
}
elseif($request.Path.EndsWith('/deployment-branch-policies') -and $policies.Count -gt 0) { continue }
elseif($request.Path.EndsWith('/environments/release') -and $releaseEnvironment.Count -gt 0) { continue }
$operations.Add($operation)
}
if($Mode -eq 'Apply')
{
if(-not $CheckSha) { throw 'Apply requires the exact successful bootstrap PR CheckSha.' }
$pulls = @(Invoke-RepositoryApi "$root/pulls?state=open&base=main&per_page=100")
if(@($pulls | Where-Object { $_.head.sha -ceq $CheckSha }).Count -ne 1)
{
throw 'CheckSha must be the current head of one open bootstrap PR targeting main.'
}
$checks = Invoke-RepositoryApi "$root/commits/$CheckSha/check-runs?per_page=100"
foreach($context in $Plan.Contexts)
{
$check = @($checks.check_runs | Where-Object { $_.name -ceq $context -and $_.app.slug -eq 'github-actions' -and $_.app.id -eq $actionsApp.id } |
Sort-Object started_at -Descending | Select-Object -First 1)
if($check.Count -ne 1 -or $check[0].conclusion -ne 'success') { throw "Bootstrap check not successful: $context. No settings were changed." }
}
foreach($operation in $operations)
{
if($PSCmdlet.ShouldProcess($Plan.Repository, "$($operation.Method) $($operation.Path)"))
{
$null = Invoke-RepositoryApi -Path $operation.Path -Method $operation.Method -Body $operation.Body
}
}
if($WhatIfPreference) { return @{ Mode = 'Preview'; PendingRequests = $operations.Count } }
}
$problems = [System.Collections.Generic.List[string]]::new()
foreach($request in $Plan.Requests)
{
if($request.Method -eq 'POST')
{
if($request.Path.EndsWith('/rulesets'))
{
$currentRules = @(Invoke-RepositoryApi "$root/rulesets?includes_parents=true&per_page=100")
$existing = @($currentRules | Where-Object name -EQ $request.Body.name)
if($existing.Count -ne 1) { $problems.Add("Missing unique $($request.Body.name)"); continue }
$actual = Invoke-RepositoryApi "$root/rulesets/$($existing[0].id)"
$expected = $request.Body
}
else
{
if($releaseEnvironment.Count -eq 0 -and $Mode -eq 'Validate') { $problems.Add('Missing release tag policy'); continue }
$actual = Invoke-RepositoryApi "$root/environments/release/deployment-branch-policies?per_page=100"
if(@($actual.branch_policies).Count -ne 1 -or -not (Test-RepositoryValue $actual.branch_policies[0] $request.Body))
{
$problems.Add('Release must permit only v* tags')
}
continue
}
}
else
{
if($request.Path.EndsWith('/environments/release') -and $releaseEnvironment.Count -eq 0 -and $Mode -eq 'Validate')
{
$problems.Add('Missing release environment'); continue
}
$actual = Invoke-RepositoryApi $request.Path
$expected = if($request.ContainsKey('Expected')) { $request.Expected } else { $request.Body }
}
if(-not (Test-RepositoryValue $actual $expected)) { $problems.Add("Configuration mismatch: $($request.Path)") }
}
$analyses = @(Invoke-RepositoryApi "$root/code-scanning/analyses?per_page=1")
if($analyses.Count -eq 0) { $problems.Add('Code scanning has no successful published analysis') }
if($problems.Count -gt 0) { throw ($problems -join "`n") }
return @{ Mode = $Mode; Repository = $Plan.Repository; SettingsVerified = $true; RequiredContexts = $Plan.Contexts }
}
Export-ModuleMember -Function Get-GitHubRepositoryPlan, Invoke-GitHubRepositorySetup
+39
View File
@@ -0,0 +1,39 @@
#Requires -Version 7.4
# Copyright (c) 2026 James Maes (KofTwentyTwo)
# SPDX-License-Identifier: MIT
<#
.SYNOPSIS
Previews, validates or applies generated-repository GitHub controls.
.DESCRIPTION
Plan is offline and is the default. Apply requires an authorized administrator and
successful checks on the bootstrap PR SHA. Existing conflicting protections are
preserved. This tool never creates a repository, pushes code, merges or publishes.
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9_.-]+$')] [string] $Repository = 'KofTwentyTwo/MarkdownViewr.Windows',
[ValidateSet('Plan', 'Validate', 'Apply')] [string] $Mode = 'Plan',
[ValidateRange(1, 100)] [int] $MaintainerCount = 1,
[ValidatePattern('^[a-f0-9]{40}$')] [string] $CheckSha,
[string] $OutputPath = 'artifacts/repository-setup.json'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot 'GitHubRepository.psm1') -Force
$plan = Get-GitHubRepositoryPlan -Repository $Repository -MaintainerCount $MaintainerCount
if($Mode -eq 'Plan')
{
$OutputPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
if($PSCmdlet.ShouldProcess($OutputPath, 'Write non-secret GitHub configuration plan'))
{
$parent = Split-Path $OutputPath -Parent
New-Item -ItemType Directory -Force -Path $parent | Out-Null
$plan | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $OutputPath -Encoding utf8NoBOM
}
return
}
$setupArguments = @{ Plan = $plan; Mode = $Mode; WhatIf = $WhatIfPreference }
if($CheckSha) { $setupArguments.CheckSha = $CheckSha }
Invoke-GitHubRepositorySetup @setupArguments
+61
View File
@@ -0,0 +1,61 @@
#Requires -Version 7.4
# Copyright (c) 2026 James Maes
# SPDX-License-Identifier: MIT
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Describe 'Installed update smoke boundaries' `
{
BeforeAll `
{
$script:SmokeHarness = Join-Path $PSScriptRoot 'Test-InstalledUpdates.ps1'
$script:SmokeArtifacts = Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts'
$script:SmokeAppId = [regex]::Match((Get-Content -LiteralPath (Join-Path (Split-Path -Parent $PSScriptRoot) 'src/MarkdownViewr.Windows.Core/AppIdentity.cs') -Raw), '(?m)^\s*Id\s*=\s*"([a-z0-9][a-z0-9-]*)"').Groups[1].Value
$ast = [Management.Automation.Language.Parser]::ParseFile($script:SmokeHarness, [ref]$null, [ref]$null)
$versionCheck = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Assert-SmokeCliVersion' }, $true)
. ([scriptblock]::Create($versionCheck.Extent.Text))
}
It 'accepts CLI version without Git metadata outside a checkout' `
{
{ Assert-SmokeCliVersion -Output "Public API true 0.1.0`r`n" -Version '0.1.0' } | Should -Not -Throw
}
It 'accepts a prerelease version with SourceLink metadata' `
{
{ Assert-SmokeCliVersion -Output "Public API true 0.3.0-beta.1 (123456789)`r`n" -Version '0.3.0-beta.1' } | Should -Not -Throw
}
It 'rejects an extended or different version token: <Actual>' -ForEach @(
@{ Actual = '0.1.01' }, @{ Actual = '0.1.0-beta.1' }, @{ Actual = '0.4.0' }
) `
{
{ Assert-SmokeCliVersion -Output "Public API true $Actual (123456789)`r`n" -Version '0.1.0' } | Should -Throw '*incorrect version*'
}
It 'refuses output outside the repository artifact boundary before running tools' `
{
{ & $script:SmokeHarness -OutputDirectory (Join-Path $TestDrive 'outside') } | Should -Throw '*inside this repository artifacts*'
Join-Path $TestDrive 'outside' | Should -Not -Exist
}
It 'refuses an identity mismatch that would fail to isolate the real user profile' `
{
$output = Join-Path $script:SmokeArtifacts ('identity-rejected-' + [guid]::NewGuid().ToString('N'))
{ & $script:SmokeHarness -AppId 'incorrect-smoke-identity' -OutputDirectory $output } | Should -Throw '*AppId must match*'
$output | Should -Not -Exist
}
It 'refuses existing output instead of overwriting or consuming stale results' `
{
$output = Join-Path $script:SmokeArtifacts ('existing-rejected-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $output | Out-Null
try
{
{ & $script:SmokeHarness -AppId $script:SmokeAppId -VpkPath (Get-Command dotnet -CommandType Application).Source -OutputDirectory $output } | Should -Throw '*fresh smoke output*'
@(Get-ChildItem -LiteralPath $output).Count | Should -Be 0
}
finally { Remove-Item -LiteralPath $output }
}
}
+128
View File
@@ -0,0 +1,128 @@
#Requires -Version 7.4
# Copyright (c) 2026 James Maes (KofTwentyTwo)
# SPDX-License-Identifier: MIT
<#
.SYNOPSIS
Generates optional stable Scoop and Chocolatey manifests from final release bytes.
.DESCRIPTION
Accepts a final x64 ZIP; checks its layout and hashes the actual bytes. No network,
credentials or publishing are involved. Package managers own portable updates.
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)] [ValidatePattern('^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$')] [string] $Version,
[Parameter(Mandatory)] [string] $ArtifactPath,
[ValidateSet('Cli', 'Portable')] [string] $ArtifactKind = 'Portable',
[ValidatePattern('^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9_.-]+$')] [string] $Repository = 'KofTwentyTwo/MarkdownViewr.Windows',
[ValidatePattern('^[a-z][a-z0-9-]*$')] [string] $AppId = 'markdownviewr-windows',
[ValidatePattern('^[A-Za-z0-9_.-]+\.exe$')] [string] $MainExe = 'MarkdownViewr.Windows.exe',
[string] $DisplayName = 'MarkdownViewr',
[string] $Description = 'A Windows Markdown viewer with live reload, themes and document navigation.',
[string] $Author = 'James Maes',
[string] $OutputDirectory = 'artifacts/distribution'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$artifact = Get-Item -LiteralPath $ArtifactPath
$executable = if($ArtifactKind -eq 'Cli') { "$AppId.exe" } else { $MainExe }
$assetName = $artifact.Name
if($ArtifactKind -eq 'Cli' -and $assetName -cne "$AppId-cli-win-x64-$Version.zip")
{
throw 'CLI asset name does not match the app identity and stable version.'
}
if($ArtifactKind -eq 'Portable' -and $assetName -cne "$AppId-stable-Portable.zip")
{
throw 'Portable asset name does not match the stable Windows release asset.'
}
$archive = [IO.Compression.ZipFile]::OpenRead($artifact.FullName)
try
{
$entries = @($archive.Entries | ForEach-Object { $_.FullName.Replace('\', '/') })
if(@($entries | Where-Object { $_ -match '(^/|^[A-Za-z]:|(^|/)\.\.(/|$))' }).Count -gt 0)
{
throw 'Archive contains an unsafe path.'
}
if(@($entries | Where-Object { $_ -ceq $executable }).Count -ne 1)
{
throw "Release archive must contain exactly one root $executable."
}
}
finally
{
$archive.Dispose()
}
$sha256 = (Get-FileHash -LiteralPath $artifact.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
$homepage = "https://github.com/$Repository"
$url = "$homepage/releases/download/v$Version/$assetName"
$packageId = if($ArtifactKind -eq 'Cli') { "$AppId-cli" } else { "$AppId-portable" }
$scoop = [ordered]@{
version = $Version; description = $Description; homepage = $homepage; license = 'MIT'
architecture = @{ '64bit' = @{ url = $url; hash = $sha256 } }
notes = 'Use this package manager to update. GUI Velopack portable bundles can also self-update; avoid mixing update methods.'
}
if($ArtifactKind -eq 'Cli') { $scoop.bin = $executable }
else { $scoop.shortcuts = , @($executable, $DisplayName) }
# Serialize text as data for both XML and PowerShell; never interpolate raw metadata as code.
$xmlValues = @{}
foreach($pair in @{ id = $packageId; version = $Version; title = $DisplayName; authors = $Author; description = $Description; homepage = $homepage }.GetEnumerator())
{
$xmlValues[$pair.Key] = [Security.SecurityElement]::Escape($pair.Value)
}
$nuspec = @"
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
<metadata>
<id>$($xmlValues.id)</id>
<version>$($xmlValues.version)</version>
<title>$($xmlValues.title)</title>
<authors>$($xmlValues.authors)</authors>
<description>$($xmlValues.description)</description>
<projectUrl>$($xmlValues.homepage)</projectUrl>
<licenseUrl>$($xmlValues.homepage)/blob/main/LICENSE</licenseUrl>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<releaseNotes>$($xmlValues.homepage)/releases/tag/v$Version</releaseNotes>
<tags>windows portable $ArtifactKind</tags>
</metadata>
<files><file src="tools\**" target="tools" /></files>
</package>
"@
$install = @'
# Copyright (c) 2026 James Maes (KofTwentyTwo)
# SPDX-License-Identifier: MIT
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$toolsDirectory = Split-Path -Parent $MyInvocation.MyCommand.Definition
$destination = Join-Path $toolsDirectory 'app'
$packageArguments = @{
packageName = '__PACKAGE__'
unzipLocation = $destination
url64bit = '__URL__'
checksum64 = '__HASH__'
checksumType64 = 'sha256'
}
Install-ChocolateyZipPackage @packageArguments
$mainExecutable = Join-Path $destination '__EXE__'
foreach($binary in Get-ChildItem -LiteralPath $destination -Recurse -Filter '*.exe')
{
if($binary.FullName -ne $mainExecutable)
{
New-Item -ItemType File -Force -Path "$($binary.FullName).ignore" | Out-Null
}
}
'@
$install = $install.Replace('__PACKAGE__', $packageId).Replace('__URL__', $url.Replace("'", "''")).Replace('__HASH__', $sha256).Replace('__EXE__', $executable)
$OutputDirectory = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputDirectory)
if($PSCmdlet.ShouldProcess($OutputDirectory, "Generate $packageId $Version manifests from SHA256 $sha256"))
{
$bucket = Join-Path $OutputDirectory 'bucket'
$chocolatey = Join-Path $OutputDirectory "chocolatey/$packageId"
$toolsDirectory = Join-Path $chocolatey 'tools'
New-Item -ItemType Directory -Force -Path $bucket, $toolsDirectory | Out-Null
$scoop | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $bucket "$packageId.json") -Encoding utf8NoBOM
[IO.File]::WriteAllText((Join-Path $chocolatey "$packageId.nuspec"), $nuspec + "`n", [Text.UTF8Encoding]::new($false))
[IO.File]::WriteAllText((Join-Path $toolsDirectory 'chocolateyinstall.ps1'), $install + "`n", [Text.UTF8Encoding]::new($false))
Copy-Item -LiteralPath (Join-Path $PSScriptRoot '../LICENSE') -Destination (Join-Path $toolsDirectory 'LICENSE.txt')
}
+59
View File
@@ -0,0 +1,59 @@
#Requires -Version 7.4
# Copyright (c) 2026 James Maes
# SPDX-License-Identifier: MIT
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
BeforeAll `
{
$script:PackageByteGate = Join-Path $PSScriptRoot 'Assert-PackageBytes.ps1'
}
Describe 'Final package byte preservation' `
{
BeforeEach `
{
$script:ByteFixture = Join-Path $TestDrive ([guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path "$script:ByteFixture/source", "$script:ByteFixture/portable/current", "$script:ByteFixture/full/lib/app" | Out-Null
foreach($directory in @('source', 'portable/current', 'full/lib/app'))
{
Copy-Item -LiteralPath (Join-Path $PSHOME 'System.Runtime.dll') -Destination "$script:ByteFixture/$directory/Runtime.dll"
}
}
It 'accepts exact source bytes with the actual Velopack archive prefixes' `
{
[IO.Compression.ZipFile]::CreateFromDirectory("$script:ByteFixture/portable", "$script:ByteFixture/portable.zip")
[IO.Compression.ZipFile]::CreateFromDirectory("$script:ByteFixture/full", "$script:ByteFixture/full.nupkg")
& $script:PackageByteGate -PublishDirectory "$script:ByteFixture/source" -PortableArchive "$script:ByteFixture/portable.zip" -FullPackage "$script:ByteFixture/full.nupkg" | Should -BeLike 'Verified 1*'
}
It 'rejects changed framework bytes even when file length is unchanged' `
{
$changed = [IO.File]::ReadAllBytes("$script:ByteFixture/portable/current/Runtime.dll")
$changed[0] = 0
[IO.File]::WriteAllBytes("$script:ByteFixture/portable/current/Runtime.dll", $changed)
[IO.Compression.ZipFile]::CreateFromDirectory("$script:ByteFixture/portable", "$script:ByteFixture/portable.zip")
[IO.Compression.ZipFile]::CreateFromDirectory("$script:ByteFixture/full", "$script:ByteFixture/full.nupkg")
{ & $script:PackageByteGate -PublishDirectory "$script:ByteFixture/source" -PortableArchive "$script:ByteFixture/portable.zip" -FullPackage "$script:ByteFixture/full.nupkg" } | Should -Throw '*changed published bytes*Runtime.dll*'
}
It 'rejects a full package that omits a dependency' `
{
Remove-Item -LiteralPath "$script:ByteFixture/full/lib/app/Runtime.dll"
[IO.Compression.ZipFile]::CreateFromDirectory("$script:ByteFixture/portable", "$script:ByteFixture/portable.zip")
[IO.Compression.ZipFile]::CreateFromDirectory("$script:ByteFixture/full", "$script:ByteFixture/full.nupkg")
{ & $script:PackageByteGate -PublishDirectory "$script:ByteFixture/source" -PortableArchive "$script:ByteFixture/portable.zip" -FullPackage "$script:ByteFixture/full.nupkg" } | Should -Throw '*omits published file*Runtime.dll*'
}
It 'rejects a corrupt published executable header before comparing archives' `
{
$changed = [IO.File]::ReadAllBytes("$script:ByteFixture/source/Runtime.dll")
$changed[0] = 0
[IO.File]::WriteAllBytes("$script:ByteFixture/source/Runtime.dll", $changed)
[IO.Compression.ZipFile]::CreateFromDirectory("$script:ByteFixture/portable", "$script:ByteFixture/portable.zip")
[IO.Compression.ZipFile]::CreateFromDirectory("$script:ByteFixture/full", "$script:ByteFixture/full.nupkg")
{ & $script:PackageByteGate -PublishDirectory "$script:ByteFixture/source" -PortableArchive "$script:ByteFixture/portable.zip" -FullPackage "$script:ByteFixture/full.nupkg" } | Should -Throw '*Invalid published PE binary: Runtime.dll*'
}
}
+93
View File
@@ -0,0 +1,93 @@
#Requires -Version 7.4
# Copyright (c) 2026 James Maes
# SPDX-License-Identifier: MIT
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
BeforeAll `
{
$script:Preflight = Join-Path $PSScriptRoot 'Assert-ReleaseSigning.ps1'
# Fixtures remain valid after an owner enables signing in the actual caller.
$script:Caller = "jobs:`n release:`n with:`n sign: false`n"
$script:Settings = @{
AZURE_CLIENT_ID = '11111111-1111-4111-8111-111111111111'
AZURE_TENANT_ID = '22222222-2222-4222-8222-222222222222'
AZURE_SUBSCRIPTION_ID = '33333333-3333-4333-8333-333333333333'
ARTIFACT_SIGNING_ENDPOINT = 'https://eus.codesigning.azure.net'
ARTIFACT_SIGNING_ACCOUNT = 'example-signing'
ARTIFACT_SIGNING_PROFILE = 'releases'
ARTIFACT_SIGNING_SUBJECT = 'CN=Verified Example Publisher'
}
$script:OldSettings = @{}
foreach($name in $script:Settings.Keys) { $script:OldSettings[$name] = [Environment]::GetEnvironmentVariable($name) }
}
AfterAll `
{
foreach($name in $script:OldSettings.Keys) { [Environment]::SetEnvironmentVariable($name, $script:OldSettings[$name]) }
}
Describe 'Application release signing preflight' `
{
BeforeEach `
{
foreach($name in $script:Settings.Keys) { [Environment]::SetEnvironmentVariable($name, $script:Settings[$name]) }
$script:Workflow = Join-Path $TestDrive 'release.yml'
$script:EnabledCaller = $script:Caller -replace '(?m)^ sign: false.*$', " sign: true`n signing-files: |`n app/MarkdownViewr.Windows.dll"
Set-Content -LiteralPath $script:Workflow -Value $script:EnabledCaller
}
It 'accepts complete configuration without contacting Azure' `
{
{ & $script:Preflight -WorkflowPath $script:Workflow } | Should -Not -Throw
}
It 'blocks an unsigned caller even when environment variables are present' `
{
Set-Content -LiteralPath $script:Workflow -Value $script:Caller
{ & $script:Preflight -WorkflowPath $script:Workflow } | Should -Throw '*Release signing is required*'
}
It 'blocks signing without an explicit owned-file catalog' `
{
Set-Content -LiteralPath $script:Workflow -Value ($script:Caller -replace '(?m)^ sign: false.*$', ' sign: true')
{ & $script:Preflight -WorkflowPath $script:Workflow } | Should -Throw '*explicit signing-files catalog*'
}
It 'blocks an empty owned-file catalog' `
{
Set-Content -LiteralPath $script:Workflow -Value ($script:EnabledCaller -replace '(?m)^ app/.*$', '')
{ & $script:Preflight -WorkflowPath $script:Workflow } | Should -Throw '*explicit signing-files catalog*'
}
It 'blocks a missing publisher subject before release work starts' `
{
$env:ARTIFACT_SIGNING_SUBJECT = ''
{ & $script:Preflight -WorkflowPath $script:Workflow } | Should -Throw '*Missing verified signing variable: ARTIFACT_SIGNING_SUBJECT*'
}
It 'does not accept a directory domain as the tenant GUID' `
{
$env:AZURE_TENANT_ID = 'example.onmicrosoft.com'
{ & $script:Preflight -WorkflowPath $script:Workflow } | Should -Throw '*Invalid signing identifier: AZURE_TENANT_ID*'
}
It 'rejects an empty client identifier' `
{
$env:AZURE_CLIENT_ID = [guid]::Empty.ToString()
{ & $script:Preflight -WorkflowPath $script:Workflow } | Should -Throw '*Invalid signing identifier: AZURE_CLIENT_ID*'
}
It 'rejects an unrelated endpoint that must not receive signing requests' `
{
$env:ARTIFACT_SIGNING_ENDPOINT = 'https://example.com'
{ & $script:Preflight -WorkflowPath $script:Workflow } | Should -Throw '*endpoint must use HTTPS*'
}
It 'rejects an unencrypted signing endpoint' `
{
$env:ARTIFACT_SIGNING_ENDPOINT = 'http://eus.codesigning.azure.net'
{ & $script:Preflight -WorkflowPath $script:Workflow } | Should -Throw '*endpoint must use HTTPS*'
}
}
+264
View File
@@ -0,0 +1,264 @@
# Copyright (c) 2026 James Maes
# SPDX-License-Identifier: MIT
<#
.SYNOPSIS
Packages four local starter releases and verifies installed stable/dev update isolation.
.DESCRIPTION
Uses a unique package ID, bounded directories and offline GitHub response fixtures.
Runs on an interactive Windows desktop. Never publishes releases or uses signing credentials.
#>
[CmdletBinding()]
param(
[string]$AppId = 'markdownviewr-windows',
[string]$VpkPath = 'vpk',
[string]$OutputDirectory = ('artifacts/installed-smoke-' + [guid]::NewGuid().ToString('N'))
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$repositoryRoot = Split-Path -Parent $PSScriptRoot
$outputRoot = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputDirectory)
$allowedRoot = [IO.Path]::GetFullPath((Join-Path $repositoryRoot 'artifacts')) + [IO.Path]::DirectorySeparatorChar
if(-not $outputRoot.StartsWith($allowedRoot, [StringComparison]::OrdinalIgnoreCase)) { throw 'Smoke output must be inside this repository artifacts directory.' }
if(-not $IsWindows) { throw 'Installed smoke tests require Windows.' }
$solution = @(Get-ChildItem -LiteralPath $repositoryRoot -Filter '*.slnx')
if($solution.Count -ne 1) { throw 'Expected exactly one app solution.' }
$name = $solution[0].BaseName
$identity = [regex]::Match((Get-Content -LiteralPath "$repositoryRoot/src/$name.Core/AppIdentity.cs" -Raw), '(?m)^\s*Id\s*=\s*"([a-z0-9][a-z0-9-]*)"')
if(-not $identity.Success -or $identity.Groups[1].Value -cne $AppId) { throw 'AppId must match the declared AppIdentity to isolate real profile data.' }
$mainExe = "$name.exe"
$probeExe = "$name.UpdateProbe.exe"
$vpk = (Get-Command $VpkPath -CommandType Application -ErrorAction Stop).Source
if(Test-Path -LiteralPath $outputRoot) { throw 'Choose a fresh smoke output directory.' }
$outputDrive = [IO.DriveInfo]::new([IO.Path]::GetPathRoot($outputRoot))
if($outputDrive.AvailableFreeSpace -lt 8GB) { throw 'Installed smoke needs at least 8 GB free on the output volume. Move completed fixtures before retrying.' }
New-Item -ItemType Directory -Path $outputRoot | Out-Null
$idFile = Join-Path $outputRoot 'package-id.txt'
$packageId = 'k22-smoke-' + [guid]::NewGuid().ToString('N')
$buildRoot = Join-Path ([IO.Path]::GetTempPath()) $packageId
if(Test-Path -LiteralPath $buildRoot) { throw 'Private build directory already exists.' }
New-Item -ItemType Directory -Path $buildRoot | Out-Null
Set-Content -LiteralPath $idFile -Value $packageId
Set-Content -LiteralPath (Join-Path $outputRoot 'build-directory.txt') -Value $buildRoot
$fixtureRoot = Join-Path $outputRoot 'fixture'
$dataVariable = $AppId.ToUpperInvariant().Replace('-', '_') + '_DATA_DIR'
$oldDataDirectory = [Environment]::GetEnvironmentVariable($dataVariable)
$oldUiExe = $env:K22_UITEST_EXE
$versions = @(
@{ channel = 'stable'; version = '0.1.0'; target = '0.4.0' },
@{ channel = 'stable'; version = '0.4.0'; target = $null },
@{ channel = 'dev'; version = '0.3.0-beta.1'; target = '0.3.0-beta.2' },
@{ channel = 'dev'; version = '0.3.0-beta.2'; target = $null }
)
$script:smokeProcessNumber = 0
<#
.SYNOPSIS
Runs one process with literal arguments, bounded duration and checked exit status.
#>
function Invoke-SmokeProcess
{
[CmdletBinding()]
param([string]$File, [string[]]$Arguments, [int]$TimeoutSeconds = 180)
$start = [Diagnostics.ProcessStartInfo]::new($File)
$start.UseShellExecute = $false
$start.CreateNoWindow = $true
$start.WindowStyle = [Diagnostics.ProcessWindowStyle]::Hidden
$start.WorkingDirectory = $repositoryRoot
$start.RedirectStandardOutput = $true
$start.RedirectStandardError = $true
if($File -eq $vpk)
{
$start.Environment['VELOPACK_TEMP'] = Join-Path ([IO.Path]::GetTempPath()) $packageId
}
foreach($argument in $Arguments) { $start.ArgumentList.Add($argument) }
$process = [Diagnostics.Process]::Start($start)
$stdout = $process.StandardOutput.ReadToEndAsync()
$stderr = $process.StandardError.ReadToEndAsync()
try
{
if(-not $process.WaitForExit($TimeoutSeconds * 1000))
{
$process.Kill($true)
throw "Process timed out: $File"
}
$script:smokeProcessNumber++
$log = Join-Path $outputRoot "process-$script:smokeProcessNumber.log"
$text = $stdout.GetAwaiter().GetResult() + $stderr.GetAwaiter().GetResult()
Set-Content -LiteralPath $log -Value $text
if($process.ExitCode -ne 0) { throw "Process failed ($($process.ExitCode)): $File. Log: $log`n$text" }
}
finally { $process.Dispose() }
}
<#
.SYNOPSIS
Reads the result emitted from a real loose, portable or installed probe process.
#>
function Get-SmokeResult
{
[CmdletBinding()]
param([string]$Exe, [string]$ResultPath, [string]$Mode = 'check')
Invoke-SmokeProcess -File $Exe -Arguments @($fixtureRoot, $ResultPath, $Mode)
if(-not (Test-Path -LiteralPath $ResultPath)) { throw 'Probe did not emit a result.' }
$result = Get-Content -LiteralPath $ResultPath -Raw | ConvertFrom-Json
if($result.UpdateDriver -ne 'AppKit') { throw 'Probe did not use the public AppKit update service.' }
if($result.AppKitSupported -and $null -ne $result.AppKitCheckError) { throw 'Public AppKit update check failed.' }
if($Mode -eq 'apply' -and (-not $result.AppKitApplyCalled -or $null -ne $result.AppKitApplyError)) { throw 'Public AppKit download/apply failed or was not called.' }
$result
}
<#
.SYNOPSIS
Checks the CLI's complete version token with or without optional SourceLink metadata.
#>
function Assert-SmokeCliVersion
{
[CmdletBinding()]
param([string]$Output, [string]$Version)
$pattern = ' ' + [regex]::Escape($Version) + '(?: \([^()\r\n]+\))?$'
if($Output.TrimEnd() -cnotmatch $pattern) { throw 'Published CLI reports an incorrect version.' }
}
<#
.SYNOPSIS
Closes only the app process launched from the exact test installation directory.
#>
function Stop-SmokeApp
{
[CmdletBinding(SupportsShouldProcess)]
param([string]$Exe)
foreach($process in @(Get-Process -Name $name -ErrorAction SilentlyContinue))
{
try
{
if($process.Path -eq $Exe -and $PSCmdlet.ShouldProcess($Exe, 'Close isolated smoke application'))
{
[void]$process.CloseMainWindow()
if(-not $process.WaitForExit(5000)) { $process.Kill() }
}
}
finally { $process.Dispose() }
}
}
$records = [Collections.Generic.List[object]]::new()
try
{
foreach($release in $versions)
{
$version = $release.version
Write-Output "Packaging $version ($($release.channel))"
$publish = Join-Path $buildRoot "publish/$version"
$probe = Join-Path $buildRoot "probe/$version"
Invoke-SmokeProcess -File 'dotnet' -Arguments @('publish', "$repositoryRoot/src/$name/$name.csproj", '-c', 'Release', '-r', 'win-x64', '-p:Platform=x64', "-p:Version=$version", '-p:SelfContained=true', '-p:WindowsAppSDKSelfContained=true', '-p:RestoreLockedMode=true', '-o', $publish) -TimeoutSeconds 300
Invoke-SmokeProcess -File 'dotnet' -Arguments @('publish', "$repositoryRoot/tests/$name.UpdateProbe/$name.UpdateProbe.csproj", '-c', 'Release', '-r', 'win-x64', "-p:Version=$version", '-p:SelfContained=true', '-p:RestoreLockedMode=true', '-o', $probe) -TimeoutSeconds 300
foreach($extension in @('.exe', '.dll', '.deps.json', '.runtimeconfig.json'))
{
Copy-Item -LiteralPath (Join-Path $probe "$name.UpdateProbe$extension") -Destination $publish
}
if(Test-Path -LiteralPath "$repositoryRoot/src/$name.Cli/$name.Cli.csproj")
{
$cli = Join-Path $buildRoot "cli/$version"
Invoke-SmokeProcess -File 'dotnet' -Arguments @('publish', "$repositoryRoot/src/$name.Cli/$name.Cli.csproj", '-c', 'Release', '-r', 'win-x64', "-p:Version=$version", '-p:SelfContained=true', '-p:PublishSingleFile=true', '-p:RestoreLockedMode=true', '-o', $cli) -TimeoutSeconds 300
Invoke-SmokeProcess -File (Join-Path $cli "$AppId.exe") -Arguments @('--version')
$versionOutput = Get-Content -LiteralPath (Join-Path $outputRoot "process-$script:smokeProcessNumber.log") -Raw
Assert-SmokeCliVersion -Output $versionOutput -Version $version
}
Invoke-SmokeProcess -File $vpk -Arguments @('pack', '--runtime', 'win-x64', '--packId', $packageId, '--packVersion', $version, '--packDir', $publish, '--mainExe', $mainExe, '--channel', $release.channel, '--outputDir', (Join-Path $fixtureRoot $version), '--shortcuts', 'None', '--delta', 'None', '--skip-updates', '--yes') -TimeoutSeconds 300
& "$PSScriptRoot/Assert-PackageBytes.ps1" -PublishDirectory $publish -PortableArchive (Join-Path $fixtureRoot "$version/$packageId-$($release.channel)-Portable.zip") -FullPackage (Join-Path $fixtureRoot "$version/$packageId-$version-$($release.channel)-full.nupkg")
}
$releases = foreach($release in $versions)
{
$version = $release.version
$assets = foreach($file in Get-ChildItem -LiteralPath (Join-Path $fixtureRoot $version) -File)
{
@{ name = $file.Name; browser_download_url = "https://github.com/Example/InstalledProbe/releases/download/$version/$($file.Name)" }
}
@{ name = "v$version"; prerelease = $release.channel -eq 'dev'; published_at = '2026-10-10T12:00:00Z'; assets = @($assets) }
}
ConvertTo-Json -InputObject @($releases) -Depth 8 | Set-Content -LiteralPath (Join-Path $fixtureRoot 'releases.json')
Invoke-SmokeProcess -File 'dotnet' -Arguments @('build', $solution[0].FullName, '-p:Platform=x64', '-p:RestoreLockedMode=true', '-warnaserror') -TimeoutSeconds 300
$loose = Get-SmokeResult -Exe (Join-Path $buildRoot "publish/0.1.0/$probeExe") -ResultPath (Join-Path $outputRoot 'loose.json')
if($loose.Installed -or $loose.AppKitSupported -or $null -ne $loose.Version) { throw 'Loose publish output unexpectedly supports updates.' }
$records.Add(@{ kind = 'loose'; result = $loose })
$env:K22_UITEST_EXE = Join-Path $buildRoot "publish/0.1.0/$mainExe"
Invoke-SmokeProcess -File 'dotnet' -Arguments @('test', "$repositoryRoot/tests/$name.UiTests", '--no-build', '--logger', 'trx;LogFileName=published-ui.trx', '--results-directory', $outputRoot) -TimeoutSeconds 180
foreach($initial in @($versions[0], $versions[2]))
{
$portableDirectory = Join-Path $outputRoot "portable-$($initial.channel)"
[IO.Compression.ZipFile]::ExtractToDirectory((Join-Path $fixtureRoot "$($initial.version)/$packageId-$($initial.channel)-Portable.zip"), $portableDirectory)
$portable = Get-SmokeResult -Exe (Join-Path $portableDirectory "current/$probeExe") -ResultPath (Join-Path $outputRoot "$($initial.channel)-portable.json")
if(-not $portable.Installed -or -not $portable.IsPortable -or -not $portable.AppKitSupported -or $portable.Channel -ne $initial.channel -or $portable.UpdateVersion -ne $initial.target) { throw 'Portable bundle identity/update behavior is incorrect.' }
$records.Add(@{ kind = 'portable'; result = $portable })
}
foreach($initial in @($versions[0], $versions[2]))
{
$channel = $initial.channel
$install = Join-Path $outputRoot "installed-$channel"
if(Test-Path -LiteralPath $install) { throw "Existing installation requires investigation: $install" }
$setup = Join-Path $fixtureRoot "$($initial.version)/$packageId-$channel-Setup.exe"
$data = Join-Path $outputRoot "data-$channel"
New-Item -ItemType Directory -Path $data -Force | Out-Null
'{"ShowSplashScreen":false,"CheckForUpdatesOnStartup":false}' | Set-Content -LiteralPath (Join-Path $data 'settings.json')
[Environment]::SetEnvironmentVariable($dataVariable, $data)
Invoke-SmokeProcess -File $setup -Arguments @('--silent', '--installto', $install, '--log', (Join-Path $outputRoot "install-$channel.log"))
try
{
$currentExe = Join-Path $install "current/$mainExe"
$installedProbe = Join-Path $install "current/$probeExe"
$before = Get-SmokeResult -Exe $installedProbe -ResultPath (Join-Path $outputRoot "$channel-before.json") -Mode 'apply'
if(-not $before.Installed -or -not $before.AppKitSupported -or $before.IsPortable -or $before.Version -ne $initial.version -or $before.AppKitVersion -ne $initial.version -or $before.Channel -ne $channel -or $before.UpdateVersion -ne $initial.target -or $before.IncludePrereleases -ne ($channel -eq 'dev')) { throw "Incorrect $channel installed update offer." }
$feeds = @($before.Requests | Where-Object { $_ -match '/releases\.(stable|dev)\.json$' })
if($feeds.Count -eq 0 -or @($feeds | Where-Object { $_ -notmatch "/releases\.$channel\.json$" }).Count -ne 0) { throw 'Channel feed is missing or a cross-channel feed was requested.' }
if(@($before.Requests | Where-Object { $_ -like "*/$($initial.target)/*-full.nupkg" }).Count -ne 1) { throw 'AppKit did not request the target full update package.' }
$manifest = Join-Path $install 'current/sq.version'
$deadline = [DateTime]::UtcNow.AddSeconds(90)
do
{
Start-Sleep -Milliseconds 300
$actual = if(Test-Path -LiteralPath $manifest) { [xml](Get-Content -LiteralPath $manifest -Raw) } else { $null }
} while(($null -eq $actual -or $actual.package.metadata.version -ne $initial.target) -and [DateTime]::UtcNow -lt $deadline)
if($null -eq $actual -or $actual.package.metadata.version -ne $initial.target) { throw "Update did not install $($initial.target)." }
$deadline = [DateTime]::UtcNow.AddSeconds(30)
do
{
Start-Sleep -Milliseconds 300
$started = @(Get-Process -Name $name -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $currentExe -and $_.MainWindowHandle -ne 0 })
} while($started.Count -eq 0 -and [DateTime]::UtcNow -lt $deadline)
if($started.Count -eq 0) { throw 'Updated main application did not restart with a window.' }
$installedHash = (Get-FileHash -LiteralPath (Join-Path $install "current/$name.dll") -Algorithm SHA256).Hash
$publishedHash = (Get-FileHash -LiteralPath (Join-Path $buildRoot "publish/$($initial.target)/$name.dll") -Algorithm SHA256).Hash
if($installedHash -ne $publishedHash) { throw 'Installed app bytes do not match the target published assembly.' }
Stop-SmokeApp -Exe $currentExe -Confirm:$false
$after = Get-SmokeResult -Exe $installedProbe -ResultPath (Join-Path $outputRoot "$channel-after.json")
if($after.Version -ne $initial.target -or $after.Channel -ne $channel -or $null -ne $after.UpdateVersion -or $after.AppKitVersion -ne $initial.target) { throw 'Post-upgrade identity or channel is incorrect.' }
$env:K22_UITEST_EXE = $currentExe
Invoke-SmokeProcess -File 'dotnet' -Arguments @('test', "$repositoryRoot/tests/$name.UiTests", '--no-build', '--filter', 'FullyQualifiedName!~CheckForUpdates_InALooseBuild', '--logger', "trx;LogFileName=installed-$channel-ui.trx", '--results-directory', $outputRoot) -TimeoutSeconds 180
$records.Add(@{ kind = 'installed'; channel = $channel; before = $before; after = $after; restarted = $true; appSha256 = $installedHash })
}
finally
{
Stop-SmokeApp -Exe (Join-Path $install "current/$mainExe") -Confirm:$false
$updater = Join-Path $install 'Update.exe'
if(Test-Path -LiteralPath $updater)
{
$manifestPath = Join-Path $install 'current/sq.version'
$manifestValue = [xml](Get-Content -LiteralPath $manifestPath -Raw)
if(-not $install.StartsWith($allowedRoot, [StringComparison]::OrdinalIgnoreCase) -or $manifestValue.package.metadata.id -ne $packageId) { throw 'Refusing to uninstall an unexpected application.' }
Invoke-SmokeProcess -File $updater -Arguments @('--silent', '--rootDir', $install, 'uninstall')
$deadline = [DateTime]::UtcNow.AddSeconds(10)
while((Test-Path -LiteralPath $install) -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 200 }
if(Test-Path -LiteralPath $install) { throw "Smoke installation was not removed: $install" }
if(Test-Path -LiteralPath "HKCU:/Software/Microsoft/Windows/CurrentVersion/Uninstall/$packageId") { throw 'Smoke uninstall registry entry remains.' }
}
}
}
ConvertTo-Json -InputObject @($records) -Depth 10 | Set-Content -LiteralPath (Join-Path $outputRoot 'results.json')
Write-Output "Installed stable/dev smoke passed: $outputRoot/results.json"
}
finally
{
[Environment]::SetEnvironmentVariable($dataVariable, $oldDataDirectory)
$env:K22_UITEST_EXE = $oldUiExe
}
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Coverage contract for the 100% gate (K22-TEST-10): every hand-written line of the
UI-free core and the command line counts. There is deliberately no Include filter:
coverlet measures the projects the unit tests reference whose sources are in this
repository (Core and the CLI), never test assemblies or NuGet packages, and an Include
filter would silently drop the CLI, whose assembly name (the app id) contains dashes.
Excluded, with intent:
- GeneratedCodeAttribute: source-generated JSON contexts and MVVM Toolkit members.
- ExcludeFromCodeCoverageAttribute: thin adapters a test cannot reach, each with a
justification at its declaration.
The WinUI project is not measured: its views need a live compositor and are covered
by the FlaUI tests. Logic belongs in MarkdownViewr.Windows.Core instead. -->
<RunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat Code Coverage">
<Configuration>
<Format>cobertura</Format>
<ExcludeByAttribute>GeneratedCodeAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>
+34
View File
@@ -0,0 +1,34 @@
# MarkdownViewr for Windows
Build a Windows counterpart to [Darin Kelkhoff's markdownViewr](https://github.com/darinkelkhoff/markdownViewr), using the KofTwentyTwo AppKit foundations and standards. The agreed display name is MarkdownViewr, repository MarkdownViewr.Windows, and permanent application ID markdownviewr-windows.
The current tree is a generated, building AppKit scaffold. Its greeting screen remains a sample; it is not yet a Markdown viewer. The following features are requirements, not implemented claims.
## Reference and feature scope
Reference source inspected at commit `6d08bc9d27d7d7b2c2fa95bb282da330b8cf9021`. Preserve Darin's MIT copyright/license when adapting his rendering resources or other code. macOS SwiftUI/WebKit and Sparkle integrations require Windows equivalents.
| Area | Required Windows behavior |
| --- | --- |
| Documents | Open Markdown through a picker and Windows file activation; multiple document windows; recent files; clear missing/unreadable-file errors |
| Rendering | GitHub Flavored Markdown, tables, tasks, strikethrough, fenced code, local images and links |
| Reload | Watch saves, including atomic replacements; debounce changes, preserve scroll, report deletion/recovery |
| Themes | Built-in themes, editable user themes, custom CSS, code font/size and persistent settings |
| Navigation | Resizable table of contents, depth control, heading jumps and collapsible sections |
| Source | Optional source pane, synchronized headings/scroll and collapse state |
| Find | Match counts, next/previous matches, search in both panes and vim-style shortcuts |
| Keys | j/k, Ctrl+d/u, gg/G, ]]/[[, /, n/N; Windows Ctrl-based equivalents for native commands |
| Frontmatter | Hide YAML, display metadata or render it as Markdown |
| Editor | Configurable external editor with safe literal file arguments |
| Print | Native printing, theme/layout options and restoration of screen state afterwards |
| Foundation | AppKit About, activity log, theme/settings, crash handling, stable/dev updates, tests and release controls |
## Proposed implementation for discussion
Use a WinUI 3 shell with a WebView2 document surface, a pure .NET core for document/rendering state, and thin Windows adapters for file activation, watching, editor launch and printing. Evaluate a maintained .NET Markdown parser against the original's actual output before choosing it. Reuse compatible original HTML/CSS/navigation resources where useful, retaining attribution and replacing the macOS message bridge.
Treat document Markdown, HTML, images, CSS, links and web messages as untrusted. Document content must not execute arbitrary script or gain access to native host commands. Local-resource access must remain bounded to explicitly allowed document resources; remote navigation and external editor launch require deliberate user actions. Define and test the renderer policy before loading user files.
## Acceptance
Require deterministic core tests and 100% measured core line coverage, zero-warning builds, formatting, real document UI acceptance, malicious-content tests, both installed update channels and verified final signed Windows artifacts. Existing scaffold tests prove only the foundation. Do not describe the viewer, public releases or production signing as complete until corresponding evidence exists.
+86
View File
@@ -0,0 +1,86 @@
# Releasing MarkdownViewr
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-velopack.yml` workflow in KofTwentyTwo/standards.
## Workflow baseline
Shared workflows are pinned to published standards v0.1.2. The release caller checks main ancestry and reruns build, coverage, formatting, UI tests, security scans and CodeQL before publication.
The AppKit dependency locks now match the published 0.1.0 packages. Before changing any package version, regenerate the affected locks and verify a locked restore with a clean package cache.
## Versioning and channels
The version is the tag without the leading `v`, in strict [semver](https://semver.org).
| Tag | Version | GitHub Release | Velopack channel |
| --- | --- | --- | --- |
| `v1.2.3` | `1.2.3` | stable | `stable` |
| `v1.2.3-beta.1` | `1.2.3-beta.1` | prerelease | `dev` |
An installed copy only updates within its own channel. `Directory.Build.props` holds
`VersionPrefix`, the next expected release; local builds are `<prefix>-dev`. Bump it in a
pull request right after each release.
Run the [installed channel smoke test](installed-updates.md) before releasing.
It verifies real stable/dev upgrades and published-output startup with local fixtures.
## What a release does
The shared workflow (SLSA Build L3: the build definition lives in KofTwentyTwo/standards):
1. Validates strict SemVer and repeats locked restore,
zero-warning build, unit tests, and the 100% coverage gate.
2. Publishes the app unpackaged and self-contained (and the single-file command line),
signs the owned binaries and packs them with Velopack: Setup,
portable zip, full and delta update packages.
3. Generates CycloneDX SBOMs, writes `SHA256SUMS`, and attests provenance and SBOMs.
4. Creates a draft GitHub Release with every asset and generated notes, then publishes
it (releases are immutable).
The optional `winget` job in `release.yml` then submits the manifest update for stable
releases.
Optional Scoop/Chocolatey candidate generation supports CLI and GUI portable
payloads, using final-byte hashes and verified provenance. Follow the
[distribution presets](distribution.md); no package feed is updated by default.
## One-time setup (owner)
1. **Environment** `release` (Settings → Environments) with a deployment rule that allows
only tags matching `v*`.
2. **Code signing:** complete [the signing configuration](release-signing.md), pin
its published reviewed builder, then enable `sign: true` with an explicit
owned-file catalog. The preflight blocks unsigned publication and incomplete
protected-environment configuration before any release build starts.
3. **winget** (optional): submit the first stable release by hand with `wingetcreate new`,
then add `WINGET_TOKEN` to the `release` environment and the repository variable
`WINGET_PACKAGE_ID`.
## Cutting a release
1. Make sure `main` is green and the conformance check passes:
`pwsh ../standards/tools/Test-RepoConformance.ps1 -Repository KofTwentyTwo/MarkdownViewr.Windows -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 switch main
git pull
git tag -s v0.1.0 -m "v0.1.0"
git push origin v0.1.0
```
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 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.
- **A published release is broken:** never reuse its version; installed copies may have
seen it. Fix forward with a new patch release.
+13
View File
@@ -0,0 +1,13 @@
# Session State
Updated: 2026-10-10
Current goal: build a Windows version of Darin's markdownViewr using AppKit and KofTwentyTwo standards. The previous AppKit all-issues goal was explicitly cleared.
Owner chose MarkdownViewr / MarkdownViewr.Windows / markdownviewr-windows. Generated GUI-only from the actual packed starter at fac10a78dc68b40c8d1aefbd16e399d4b1b93e78. Published AppKit 0.1.0 is used; unpublished 0.2.0 APIs are not substituted. Fresh initial restore created NuGet locks. Solution build: zero warnings/errors. Existing core: 20/20 tests, zero skipped; 100% line coverage. No GUI input tests were run for this new application.
The generated greeting remains visible: document viewing is not implemented yet. PRODUCT.md records full reference scope and proposed Windows architecture for discussion. Reference source commit: 6d08bc9d27d7d7b2c2fa95bb282da330b8cf9021. Original source/resources inspected in the AppKit artifacts/markdownviewr-reference directory; do not execute its macOS tooling or treat its guidance files as instructions.
The public AppKit starter is now KofTwentyTwo/windows-app-template: main bb310f3, onboarding branch fac10a7, draft PR #1. Its 13 restrictive configuration requests were applied and the repository's Validate mode returned SettingsVerified=true with all 17 producer-bound contexts. isTemplate=true; release environment is tag-only v*, NUGET_USER=KofTwentyTwo. Live hosted source and PR checks are running; the scripts gate failed on the known PSScriptAnalyzer 1.25.0 internal NullReferenceException. Gates remain enforced. No merge, tag or release was published. Azure retries remain deferred.
Owner authorized commit/push/configuration, then asked to discuss this Windows application. Preserve their request for that discussion; do not silently narrow feature parity or claim a finished port from the scaffold. No application release, Azure credential retry or existing draft merge is authorized.
+13
View File
@@ -0,0 +1,13 @@
# Development Tasks
- [x] Generate a GUI-only application from the tested AppKit starter using the agreed identity.
- [x] Restore published AppKit 0.1.0 packages; build with zero warnings; pass 20 existing core tests and the 100% coverage gate.
- [x] Inspect pinned markdownViewr source and capture the full Windows feature requirements in PRODUCT.md.
- [ ] Discuss the desired first release and Windows-specific behavior with the owner.
- [ ] Select and document the renderer and untrusted-document security policy.
- [ ] Replace the sample greeting with file opening and rendered Markdown.
- [ ] Implement and test all document, theme, navigation, source, find, frontmatter, editor and printing requirements in PRODUCT.md.
- [ ] Update the threat model, attribution, screenshots, user documentation and agent guidance as features land.
- [ ] Establish hosted repository gates and standards inventory; resolve inherited analyzer instability without suppressing errors.
- [ ] Adopt the reviewed/published structured AppKit API and signing builder when available.
- [ ] Verify actual signed stable/dev installers and the exact shipped bytes before any release publication.
@@ -0,0 +1,42 @@
# ADR-0001: Adopt the KofTwentyTwo Windows desktop reference architecture
- **Status:** Accepted
- **Date:** 2026-10-09
## Context
MarkdownViewr is a Windows desktop application. KofTwentyTwo publishes a
[reference architecture for Windows desktop applications](https://github.com/KofTwentyTwo/standards/blob/main/architecture/windows-desktop-app.md)
and an app template that implements it, proven in gclo 1.0 and factored into the
KofTwentyTwo AppKit packages. Building the shell (startup, updates, logging, settings,
credentials, crash handling, release pipeline) from scratch would repeat that work and
miss the standards' controls.
## Options considered
### Option A: start from the KofTwentyTwo Windows app template
- Pros: compliant with the KofTwentyTwo standards from the first commit; AppKit supplies
the shell; the release pipeline is the shared SLSA Build L3 workflow.
- Cons: depends on AppKit's release cadence for shell changes.
### Option B: a hand-built WinUI app
- Pros: no external dependency for the shell.
- Cons: every shell feature, test, and pipeline control re-implemented and maintained
here.
## Decision
Option A. The app follows the reference architecture: a WinUI 3 shell project with no
logic, a UI-free `MarkdownViewr.Windows.Core` held to 100% line coverage, an optional command line over
the same core, FlaUI end-to-end tests, Velopack per-user install and self-update from
GitHub Releases, and the shared KofTwentyTwo workflows for every gate and release.
## Consequences
- Shell behavior (splash, About, activity log, updates, theme, crash net) changes by
updating the AppKit packages, not by editing this repository.
- A departure from the reference architecture needs its own ADR here.
- The threat model starts from the reference architecture's common threats
([docs/security/threat-model.md](../security/threat-model.md)).
+78
View File
@@ -0,0 +1,78 @@
# Optional package-manager distributions
Velopack Setup and portable downloads remain the standard release artifacts.
Scoop, Chocolatey and winget are optional. Choose one portable payload for the
Scoop/Chocolatey presets: the single-file CLI (when enabled) or the GUI portable ZIP.
These package managers manage upgrades themselves. A complete GUI Velopack portable
bundle also supports self-update; avoid mixing package-manager upgrades with the
app's updater. Disable startup update checks when using a manager and update through
that manager. The standalone CLI has no app updater. Settings stay under
`%LOCALAPPDATA%\markdownviewr-windows` across upgrades.
## Generate and validate
Generate manifests from the **final verified release ZIP**, after signing, packaging
and provenance verification. The generator checks identity/version, rejects unsafe
archive paths, requires the root executable, and hashes the actual archive:
```powershell
# GUI portable, available with or without a CLI:
./build/New-DistributionManifests.ps1 -Version 1.2.3 `
-ArtifactPath artifacts/markdownviewr-windows-stable-Portable.zip
# Optional CLI:
./build/New-DistributionManifests.ps1 -Version 1.2.3 -ArtifactKind Cli `
-ArtifactPath artifacts/markdownviewr-windows-cli-win-x64-1.2.3.zip
```
The output is `artifacts/distribution/bucket/<app-id>-portable.json` (or `-cli`),
a Chocolatey nuspec, installation script and license. Metadata is XML escaped;
URLs/hashes are serialized as data. Only strict stable versions are accepted.
Generation and `-WhatIf` need no credentials. Keep app IDs and package IDs permanent
after the first publication. Both presets target x64 only.
Set the repository variable `DISTRIBUTION_ARTIFACT` to `portable` or `cli` to enable
the optional stable release job (`cli` requires a generated CLI). The job downloads
the immutable release ZIP and `SHA256SUMS`, verifies the hash and GitHub provenance,
generates manifests and runs `choco pack`. It uploads the candidate manifests and
package for review. It uses only read access and the protected `release` environment.
No package feed is updated automatically; enabling generation does not authorize
publication.
## Publish selected integrations
For Scoop, review the generated JSON and propose it as `bucket/<package-id>.json`
in this repository or your dedicated bucket. Validate against Scoop's schema and
test `scoop install`, `update` and `uninstall` with a disposable user before merging.
Users add the bucket with `scoop bucket add markdownviewr-windows https://github.com/KofTwentyTwo/MarkdownViewr.Windows`.
No token is needed by the generated consumer manifest.
For Chocolatey, inspect the generated nuspec/script and packed package, then test
installation, upgrade and uninstall in a disposable Windows environment. The
script verifies SHA256 and ignores helper executables when creating command shims.
The package includes its license. Claim/reserve your actual package ID with the feed
before publishing; community moderation may require additional product metadata.
Configure any publishing credential through the approved environment/secret store
and authorized publisher tooling, never in source or command-line arguments. These
presets intentionally do not embed a feed credential.
For winget, submit the first stable signed Setup using `wingetcreate new`, then set
`WINGET_PACKAGE_ID` and the `release` environment's `WINGET_TOKEN` for subsequent
updates. A configured integration with a missing token must fail. See
[release setup](RELEASING.md#one-time-setup-owner).
The pinned winget client reads `WINGET_CREATE_GITHUB_TOKEN` from the job environment;
the workflow keeps the token out of command-line arguments, following its verified
[v1.12.13.0 token helper](https://github.com/microsoft/winget-create/blob/v1.12.13.0/src/WingetCreateCLI/TokenHelper.cs).
## Evidence limits
Fixture tests cover stable-only versions, archive identity/layout, unsafe paths,
actual-byte hashes, XML metadata and CLI/no-CLI manifest shapes. Source generation
and package construction do not prove live feed installation or upgrade behavior.
Both presets were additionally generated from provenance-verified gclo 1.0.1 CLI
and portable ZIPs and passed the installed official Scoop schema. Fresh packaged
CLI/no-CLI templates generate the correct custom owner, app ID and executable.
Keep selected integrations pending until their disposable install/upgrade/uninstall
tests and actual publisher configuration succeed.
Manifest conventions follow [Scoop's official reference](https://github.com/ScoopInstaller/Scoop/wiki/App-Manifests)
and [Chocolatey's ZIP installation API](https://docs.chocolatey.org/en-us/create/functions/install-chocolateyzippackage/).
+98
View File
@@ -0,0 +1,98 @@
# Installed release smoke tests
Run on an interactive Windows x64 desktop with the pinned .NET SDK and Velopack
1.2.161. The test packages and installs only uniquely named local fixtures; it
does not create GitHub releases, use Azure, or produce release-signing evidence.
Reserve at least 8 GB free on the repository artifact volume. The harness checks
this before building; retained archives, portable copies and installations can
otherwise exhaust the volume during extraction.
```powershell
dotnet tool install vpk --version 1.2.161 --tool-path artifacts/smoke-tools
./build/Test-InstalledUpdates.ps1 -VpkPath ./artifacts/smoke-tools/vpk.exe
```
The harness publishes the actual starter app four times with locked dependencies,
self-contained .NET and Windows App SDK settings matching the release builder.
Core and CLI declare the Windows runtime identifiers so their committed locks
support runtime-specific publishing. Their single-file analyzer setting also locks
the SDK's ILLink tool dependency used when publishing the CLI as one executable.
The harness publishes and runs each CLI version when that head exists.
Keep `VelopackApp.Build().Run()` directly in
the app's entry point: the packer's startup check cannot recognize an indirect
AppKit wrapper call. Do not disable that pack-time check.
The app's publish target explicitly includes its generated PRI and XBF resources.
This fixes the unpackaged publish omission documented in
[WindowsAppSDK#6720](https://github.com/microsoft/WindowsAppSDK/issues/6720):
without the app PRI, `MainWindow.xaml` fails to load even when Debug UI tests pass.
## What the harness checks
- Loose published output launches and passes the full UI suite; AppKit reports
self-update unavailable when no Velopack manifest/updater exists.
- Real Setup installs `stable` 0.1.0 and `dev` 0.3.0-beta.1 into separate, bounded
test directories using one unique test package ID. No shortcuts are requested.
- The probe calls AppKit's public `CheckAsync` and `DownloadAndApplyAsync` methods.
A test-only downloader maps GitHub response URLs to local assets and refuses
other URLs. Reflection replaces only transport in the pinned AppKit 0.1.0 /
Velopack 1.2.161 source, failing closed if its private symbols change. The actual
backend, update manager, locator and channel selection remain unchanged.
- Stable offers only 0.4.0; dev offers only 0.3.0-beta.2, even though the stable
version is higher. Requests must never fetch the other channel's feed.
- Velopack downloads and verifies the real package, applies it, and restarts the
actual WinUI app. Its window must appear. Post-upgrade AppKit version and channel
must match, with no remaining offer; six applicable UI tests run after each upgrade.
- Published executable headers must parse before archive comparisons. Both archive
payloads must preserve every original non-PDB publish file outside
Velopack's default exclusions. SHA256 comparisons reject changed or missing
dependencies before installation, including a locally observed corrupt runtime DLL.
- Complete portable bundles preserve their channel and support updates. They differ
from loose F5/publish output; a `.portable` marker does not disable self-update.
The probe records public check errors and apply calls, including the target asset
request before apply exits. The harness separately requires the installed target
manifest, exact application hash and restarted window. The AppKit UI's live GitHub
check/apply flow and public release hosting remain separate acceptance items. This
does not prove Authenticode signing, installer trust, deltas or ARM64/x86.
Only the probe's executable, assembly and runtime manifests are added to smoke
packages. Its framework dependency copies never overwrite the application's
published payload. The probe is never added to production publish output.
Fresh packed-template CLI and GUI-only variants each passed nineteen UI checks,
all four byte gates and both public AppKit upgrades: stable 0.1.0 to 0.4.0 and
dev 0.3.0-beta.1 to 0.3.0-beta.2. Each version preserved 532 original publish
files in both archive formats. Target manifests, application hashes, restart,
channel isolation, no remaining offer and uninstall cleanup passed. The CLI gate
also accepts correct versions without SourceLink metadata outside a Git checkout.
Hosted starter acceptance remains required.
Reserve the desktop during physical input tests. Earlier failures observed other
apps owning a clickable point despite app keyboard focus. Input now waits for an
unobscured app-owned point and foregrounds menu/control paths. Results retain
process/point observations without unrelated window text, plus isolated app logs.
Keep earlier failed runs as evidence; see AppKit
[issue #23](https://github.com/KofTwentyTwo/AppKit/issues/23).
Original corruption fixtures are retained under
[issue #22](https://github.com/KofTwentyTwo/AppKit/issues/22); successful byte
checks do not establish the corruption's cause.
## Evidence and cleanup
CI requires `installed-updates` and retains JSON results, process/installer logs
and TRX UI reports. Local evidence stays under a fresh `artifacts/installed-smoke-*`
directory. Existing output directories are refused so each attempt tests fresh
packages and results. Packer children receive a private `VELOPACK_TEMP` directory
named for the unique test package ID, isolating staging from other app builds.
Publish and probe output also use that private system temporary directory. This
matches the Windows CI staging location and avoids repeated writes into the
checkout during local diagnostics; it does not establish the corruption's cause.
`build-directory.txt` records its location. Build inputs are retained there for
investigation, while packages and reports remain under the repository artifacts
directory.
Each successful/failing installed test
attempts uninstall in `finally`, checking the bounded root and unique manifest ID
first. Unexpected existing installations fail rather than being overwritten.
See the official [Setup switches](https://docs.velopack.io/reference/cli/content/setup-windows)
and [updater switches](https://docs.velopack.io/reference/cli/content/update-windows).
+71
View File
@@ -0,0 +1,71 @@
# Windows release signing
Application releases require Authenticode signing. The generated caller starts
with `sign: false` so it remains compatible with its currently published builder,
but its preflight blocks publication until the reviewed signing builder is
published, pinned and configured. The source template's content-only NuGet package
does not contain application binaries and uses a separate release workflow.
## Configure the protected environment
Use an existing authorized Artifact Signing account and Active Public Trust
profile. Verify its deployed region/endpoint, actual tenant GUID and full
certificate publisher subject. A subscription ID or directory domain is not the
tenant GUID. Each repository needs an approved CI identity, GitHub OIDC trust
restricted to `repo:Owner/Repository:environment:release`, and the Artifact Signing
Certificate Profile Signer role scoped to its profile. Owner/Contributor alone
does not grant signing permission. Preserve other identities and assignments.
Set these non-secret variables in the protected `release` environment, permitting
only the intended `v*` release tags:
| Variable | Verified value |
| --- | --- |
| `AZURE_TENANT_ID` | Tenant GUID |
| `AZURE_CLIENT_ID` | Approved CI application/client GUID |
| `AZURE_SUBSCRIPTION_ID` | Signing resource's subscription GUID |
| `ARTIFACT_SIGNING_ENDPOINT` | Actual regional HTTPS endpoint |
| `ARTIFACT_SIGNING_ACCOUNT` | Existing signing account name |
| `ARTIFACT_SIGNING_PROFILE` | Actual active certificate profile |
| `ARTIFACT_SIGNING_SUBJECT` | Exact verified certificate subject |
Legacy `TRUSTED_SIGNING_*` configuration names remain accepted for the last four
values. Do not copy another repository's client ID without verifying its trust
and signer assignment. Template consumers do not inherit access to KofTwentyTwo's
signing service. Never export a PFX or put administrative passwords in CI.
## Enable the reviewed builder
Pin a published shared builder that supports an explicit `signing-files` catalog
and verifies the final shipped bytes/signatures. Standards v1.0.0 contains gclo's
Velopack signing fix, but lacks this catalog interface; do not enable a partial
path or pin unpublished commits. After the prepared builder is reviewed and
published, update the caller's full commit SHA/version comment and add:
```yaml
sign: true
signing-files: |
app/MarkdownViewr.Windows.exe
app/MarkdownViewr.Windows.dll
app/MarkdownViewr.Windows.Core.dll
app/KofTwentyTwo.AppKit.dll
app/KofTwentyTwo.AppKit.Updates.dll
app/KofTwentyTwo.AppKit.WinUI.dll
```
For a CLI-enabled app, include `cli/markdownviewr-windows.exe`. Adapt names to the generated
project. Keep signing confined to protected Windows release jobs using the
official pinned Azure login/signing actions and their CLI-backed OIDC credential.
Pull requests receive no signing access. Preserve third-party signatures; sign
owned binaries before packaging and generated helpers/installers through the
packaging hook. Checksums, updater metadata and attestations follow signing.
## Verify before publication
The configuration preflight does not prove authorization or a valid signature.
Run representative real signing and `signtool verify /pa /all /v <artifact>` on
the exact shipped binaries and installers. Require the expected publisher,
trusted signatures, SHA256 digests and RFC3161 timestamps. Verification failures
must stop publication. For future MSIX packaging, its Publisher must match the
verified subject. Local signing uses Microsoft's Artifact Signing Client Tools
and the installed SignTool/dlib paths with an authorized identity.
+86
View File
@@ -0,0 +1,86 @@
# Set up a generated repository
Generate the app, restore dependencies, commit the resulting locks, install the
hooks, and run [CONTRIBUTING.md](../CONTRIBUTING.md)'s checks. Node 24 and PowerShell
7.4+ are required for repository tooling; neither ships in the app.
## Preview
The default command works offline and writes thirteen non-secret API requests:
```powershell
./build/Initialize-GitHubRepository.ps1 -Repository KofTwentyTwo/MarkdownViewr.Windows
Get-Content artifacts/repository-setup.json
```
Generated names and owner are substituted. Pass `-MaintainerCount 2` when there
are multiple maintainers: this requires one independent approval. The default
single-maintainer policy requires `review / automated` and no bypass on `main`.
## Bootstrap and apply
An administrator must authorize creation and the first push separately. Create a
public repository, push the reviewed initial tree to `main`, and open a small
bootstrap PR. Wait for every documented CI context to succeed. Read the automated
review of its **current** head and post your own acknowledgement on that PR:
```text
<!-- k22-automated-review:FULL_CURRENT_PR_COMMIT_SHA -->
Tool: Codex
Findings: none
Summary: Describe the actual reviewed scope and evidence here.
Maintainer acknowledgement: I read the review and resolved or answered every finding.
```
The privileged review workflow executes trusted `main` code only. New PR commits
need a new record; stale or incomplete records fail. An agent must not acknowledge
on your behalf. Organization-owned repositories must change the configured human
maintainer in `.github/scripts/review-gate.cjs` and its tests before bootstrap.
With GitHub CLI signed in as an authorized administrator:
```powershell
$sha = gh pr view BOOTSTRAP_PR_NUMBER --json headRefOid --jq .headRefOid
./build/Initialize-GitHubRepository.ps1 -Mode Apply -CheckSha $sha -WhatIf
# After reviewing and authorizing this exact plan:
./build/Initialize-GitHubRepository.ps1 -Mode Apply -CheckSha $sha
./build/Initialize-GitHubRepository.ps1 -Mode Validate
```
The plan uses GitHub's actual job check names: reusable build checks have the
`ci /` prefix, while standalone checks are `installed-updates`, `powershell` and
`javascript`. The template source also requires `template`, which exercises both
generated variants; generated applications have no template-packaging check.
Never add a workflow-name prefix to standalone job names in required contexts.
The tool verifies the exact public target, `main`, administrator access, absence
of repository secrets/self-hosted runners, current bootstrap PR SHA and successful
GitHub Actions checks. Both apply and read-only validation resolve the official
GitHub Actions application and require its ID on every required context. Checks
from another producer and missing/wrong policy bindings fail before writes; the
reviewed offline plan remains unchanged and reusable. The tool
creates both rulesets and the tag-only `release` environment, enables security
features, restricts Actions, and reads settings back. Existing matching policy is
reused; conflicting rules or release policies stop before writes. Unrelated rules
and environment protections are retained. API failures stop immediately; earlier
successful requests may remain, so validate and resolve the reported failure before
rerunning. The tool never creates repositories, pushes, merges, tags or releases.
## Finish and verify
Register ownership/tier in the standards `MAINTAINERS.md` inventory through a
reviewed PR. Set the description/topics and review account community defaults.
Configure signing using the verified existing Azure service; keep credentials in
the protected environment and use OIDC wherever supported. Run the standards
conformance checker against the live repository and try a failing disposable PR
to prove the checks block merging. Enroll in OpenSSF Best Practices with actual
owner attestations; never add an unearned badge.
Local fixture tests prove planning, refusal boundaries, application/readback and
idempotence. A disposable live generated repository has not yet been provisioned;
that proof requires authorized creation/push and successful hosted CI.
API shapes follow GitHub's official [rulesets](https://docs.github.com/en/rest/repos/rules),
[Actions permissions](https://docs.github.com/en/rest/actions/permissions) and
[deployment branch policies](https://docs.github.com/en/rest/deployments/branch-policies)
documentation.
+92
View File
@@ -0,0 +1,92 @@
# Threat model: MarkdownViewr
<!-- K22-SDLC-03. Update in any pull request that changes the attack surface, and review
in every MINOR/MAJOR release checklist (K22-SEC-30). Method: data flow + STRIDE. -->
- **Version reviewed:** template readiness work · **Last reviewed:** 2026-10-10
## 1. What the product is and does
A Windows desktop application installed per user (no administrator rights) with
Velopack, updating itself from its GitHub Releases. It runs entirely on the user's
machine: it keeps preferences and logs in the user's profile and has no server
component. The template's example feature (a greeting) handles only text the user types.
## 2. Actors and components
| Actor or component | Description | Trust level |
| --- | --- | --- |
| User | The person running the app | Trusted for their own data |
| WinUI shell (`MarkdownViewr.Windows.exe`) | Views and startup plumbing | Runs as the user |
| Core (`MarkdownViewr.Windows.Core`) | Logic, settings, view models | Runs as the user |
| Command line (`markdownviewr-windows.exe`) | Optional scriptable head over Core | Runs as the user |
| AppKit packages | Shell services: logging, settings, secrets, updates, splash, About | Third-party code, pinned and attested |
| Velopack update service | Checks GitHub Releases, downloads and applies packages | Runs as the user |
| GitHub Releases | Update feed and download source | Untrusted network data |
## 3. Data flow and trust boundaries
```mermaid
flowchart LR
user([User]) --> shell[MarkdownViewr.Windows.exe]
user --> cli[markdownviewr-windows.exe]
shell --> core[MarkdownViewr.Windows.Core]
cli --> core
core --> data[("%LOCALAPPDATA%\markdownviewr-windows<br/>settings.json · logs")]
shell -- HTTPS --> gh[(GitHub Releases)]
subgraph machine [Trust boundary: the user's machine and account]
shell
cli
core
data
end
```
## 4. External interfaces
| Interface | Direction | Data | Authenticated? | Validated where? |
| --- | --- | --- | --- | --- |
| Window input (text boxes, menus) | In | User text | The signed-in user | `GreetingService.Greet` (length limit), each feature's Core service |
| Command line arguments | In | Command, options | The signed-in user | `CliApp.Run` (unknown commands and options rejected) |
| `settings.json` | In/out | Preferences | File ACLs of the user profile | `SettingsStore` sanitizes on load; corrupt files fall back to defaults |
| Log files | Out | Operational events, no secrets | File ACLs | Logging API takes messages only |
| Data-folder environment variable | In | A folder path | The signed-in user | Used as a path only |
| Update feed (GitHub Releases over HTTPS) | In | Release metadata and packages | TLS; package hashes checked by Velopack | Velopack |
| Windows Credential Manager | In/out | Secrets, if a feature stores any | The user's Windows login | AppKit `CredentialManagerVault` |
## 5. Assets
The integrity of the installed program and its updates (an attacker who controls them
runs code as the user), the user's preferences and any stored secrets, and availability
of the app.
## 6. Threats (STRIDE)
| # | Boundary / component | Category | Threat | Likelihood | Impact | Mitigation | Status |
| --- | --- | --- | --- | --- | --- | --- | --- |
| T1 | Update feed | Tampering | A malicious update is served to installed copies | Low | Critical | Updates only from this repository's immutable, attested GitHub Releases over HTTPS; Velopack verifies package hashes; Authenticode signing once EX-0002 closes | Mitigated (signing pending) |
| T2 | Download | Spoofing | A tampered installer on a mirror | Medium | High | README verification steps: `SHA256SUMS`, `gh attestation verify`, Authenticode | Mitigated |
| T3 | Settings file | Tampering | A crafted `settings.json` crashes the app or sets invalid values | Low | Low | Source-generated deserialization, `Sanitize` on load, defaults on failure | Mitigated |
| T4 | Logs | Information disclosure | Secrets or personal data written to logs | Low | Medium | Log calls take plain messages; secrets go only to Credential Manager (K22-SEC-71); review | Mitigated |
| T5 | Install folder | Elevation of privilege | DLL planting next to the exe | Low | High | Per-user install under `%LOCALAPPDATA%`, owned by the user; no loading from the working directory | Mitigated |
| T6 | Shell | Denial of service | An unhandled exception closes the app with unsaved work | Medium | Low | AppKit crash net logs and survives UI exceptions; one-dialog-at-a-time guard | Mitigated |
| T7 | Input | Denial of service | Very long input slows or breaks the UI | Low | Low | Length limits in Core services | Mitigated |
| T8 | Repository administration | Tampering | Setup weakens protections, trusts another check producer or targets another repository | Low | High | Offline preview; exact public target/admin checks; verify official Actions identity and every context binding in apply/readback; current-PR checks; refuse conflicting rules and broad release refs; preserve reviewed plan | Local regression tests pass; live bootstrap pending |
| T9 | PR review workflow | Elevation of privilege | Untrusted PR code runs with a checks write token | Low | High | Comment-triggered workflow executes trusted workflow SHA only; no PR checkout; human current-SHA acknowledgement required | Local tests pass; hosted enforcement pending |
| T10 | Package-manager distributions | Tampering | Manifest points at wrong bytes or an unsafe executable | Low | High | Stable-only generation; ZIP identity/layout checks; actual SHA256 and provenance verification; exclude helper shims; candidate review before feed publication | Local tests and real archive/schema checks pass; feed lifecycle pending |
| T11 | Installed update smoke | Tampering | A test overwrites a real app or deletes unrelated files | Low | Medium | Unique test package ID; fresh artifacts-only output; app identity check; literal arguments; offline URL allowlist; exact process path; root/manifest checks before uninstall | Local runtime/boundary tests pass; hosted CI pending |
## 7. Accepted risks
- EX-0002 tracks historical unsigned Windows release risk. The current application
release preflight rejects unsigned production publication. Verified deployment,
scoped CI authorization and final-artifact signature acceptance remain required;
completed publisher validation alone does not prove CI signing.
## 8. Review log
| Date | Version | Reviewer | Changes |
| --- | --- | --- | --- |
| 2026-10-09 | template baseline | maintainer | Initial model from the Windows desktop reference architecture |
| 2026-10-10 | repository setup | Codex (GPT-6) | Added administration and review workflow trust boundaries; live proof pending |
| 2026-10-10 | check producer readback | Codex (GPT-6) | Verify official Actions identity/bindings without mutating caller plans; clarify signing gate versus historical risk |
+53
View File
@@ -0,0 +1,53 @@
// Copyright (c) 2026 James Maes (KofTwentyTwo)
// SPDX-License-Identifier: MIT
import js from "@eslint/js";
import stylistic from "@stylistic/eslint-plugin";
// JavaScript-only repository tools use the shared K22-CODE-TS layout and lint
// rules. TypeScript type checking does not apply to these CommonJS scripts.
export default [
{ ignores: ["artifacts/**", "node_modules/**"] },
js.configs.recommended,
{
files: [".github/scripts/*.cjs", "eslint.config.mjs"],
plugins: { "@stylistic": stylistic },
rules: {
"@stylistic/indent": ["error", 3, { SwitchCase: 1 }],
"@stylistic/brace-style": ["error", "allman", { allowSingleLine: false }],
"@stylistic/keyword-spacing": ["error", {
before: true,
after: true,
overrides: {
if: { after: false },
for: { after: false },
while: { after: false },
switch: { after: false },
catch: { after: false },
},
}],
"@stylistic/no-multiple-empty-lines": ["error", { max: 3, maxBOF: 0, maxEOF: 0 }],
"@stylistic/lines-between-class-members": ["error", "always", { exceptAfterSingleLine: true }],
"@stylistic/semi": ["error", "always"],
"@stylistic/quotes": ["error", "double", { avoidEscape: true }],
"@stylistic/comma-dangle": ["error", "always-multiline"],
"@stylistic/eol-last": ["error", "always"],
"@stylistic/no-tabs": "error",
"@stylistic/no-trailing-spaces": "error",
"@stylistic/operator-linebreak": ["error", "before"],
"@stylistic/dot-location": ["error", "property"],
curly: ["error", "all"],
eqeqeq: ["error", "always"],
"no-console": "error",
"no-warning-comments": ["error", { terms: ["todo", "fixme"], location: "start" }],
camelcase: ["error", { properties: "never" }],
},
},
{
files: [".github/scripts/*.cjs"],
languageOptions: {
sourceType: "commonjs",
globals: { require: "readonly", module: "readonly" },
},
},
];
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestFeature"
}
}
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Packages come only from nuget.org (K22-DEP-10). Package source mapping makes the
source explicit for every package id, so a same-named package on another feed can
never be picked up (dependency confusion). -->
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
+1074
View File
File diff suppressed because it is too large. Load diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "markdownviewr-windows-repository-tools",
"private": true,
"description": "Lint and test the repository review gate; no shipped application dependencies.",
"engines": {
"node": ">=24.0.0"
},
"scripts": {
"lint": "eslint .github/scripts eslint.config.mjs --max-warnings 0",
"test": "node --test .github/scripts/review-gate.test.cjs"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@stylistic/eslint-plugin": "5.10.0",
"eslint": "10.12.0"
},
"packageManager": "npm@11.16.0"
}
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit;
using KofTwentyTwo.AppKit.Logging;
using KofTwentyTwo.AppKit.Settings;
namespace MarkdownViewr.Windows.Core;
/// <summary>
/// The application's identity, declared once and shared by the window, the command line,
/// and the tests. AppKit derives the data folder (%LOCALAPPDATA%\markdownviewr-windows), the data-folder
/// override variable, the log file names, and the credential prefix from <see cref="Info"/>.
/// </summary>
public static class AppIdentity
{
/// <summary>Who the app is: id, names, links, credits, and brand.</summary>
public static AppInfo Info { get; } = new()
{
Id = "markdownviewr-windows",
DisplayName = "MarkdownViewr",
Tagline = "Built on the KofTwentyTwo Windows app template",
Description = "A Windows Markdown viewer with live reload, themes and document navigation.",
RepositoryUrl = new Uri("https://github.com/KofTwentyTwo/MarkdownViewr.Windows"),
Author = "James Maes",
Copyright = "Copyright (c) 2026 James Maes",
// Keep the gradient in step with the mark (regenerate both with AppKit's BrandTool).
Brand = new AppBrand
{
GradientStart = "#0F766E",
GradientEnd = "#134E4A",
IconSvgPath = "Assets/Brand/app-icon.svg",
IconIcoPath = "Assets/Brand/app.ico",
},
Attributions =
[
new("KofTwentyTwo.AppKit", "MIT License"),
new("CommunityToolkit.Mvvm", "MIT License"),
new("Velopack", "MIT License"),
new("Windows App SDK / WinUI 3", "MIT License, with Microsoft binary components"),
new(".NET Runtime", "MIT License"),
],
};
/// <summary>Where the app keeps its per-user data; honors the data-folder override variable.</summary>
public static AppPaths Paths { get; } = new(Info);
/// <summary>Loads and saves <see cref="AppSettings"/> at <see cref="AppPaths.SettingsFile"/>.</summary>
public static SettingsStore<AppSettings> SettingsStore { get; } = new(Paths.SettingsFile, AppSettingsContext.Default.AppSettings);
/// <summary>Creates the app's daily activity log under <see cref="AppPaths.LogsDirectory"/>.</summary>
public static IActivityLog CreateLog() => FileActivityLog.ForApp(Info, Paths);
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Settings;
namespace MarkdownViewr.Windows.Core;
/// <summary>
/// The app's preferences: AppKit's shell settings (theme, splash, update checks) plus the
/// app's own. Add properties here, repair them in <see cref="Sanitize"/>, and they are
/// saved by <see cref="AppIdentity.SettingsStore"/>.
/// </summary>
public sealed class AppSettings : ShellSettings
{
/// <summary>The name last greeted, offered again at the next start.</summary>
public string LastName { get; set; } = "";
/// <summary>Repairs the shell settings, then restores a missing name (JSON can supply null).</summary>
public override void Sanitize()
{
base.Sanitize();
LastName ??= "";
}
}
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text.Json.Serialization;
namespace MarkdownViewr.Windows.Core;
/// <summary>Source-generated JSON serializer for <see cref="AppSettings"/>, so trimmed builds need no reflection.</summary>
[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(AppSettings))]
internal sealed partial class AppSettingsContext : JsonSerializerContext
{
}
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
namespace MarkdownViewr.Windows.Core.Greetings;
/// <summary>
/// The template's example feature: a greeting that depends on the time of day. It shows
/// the shape every feature takes: UI-free logic in Core, the clock injected through
/// <see cref="TimeProvider"/> so tests are deterministic (K22-TEST-20), and input
/// validated where it enters (K22-CODE-22). Replace it with the app's real features.
/// </summary>
public sealed class GreetingService
{
/// <summary>Longest name accepted, so a pasted document cannot become a greeting.</summary>
public const int MaxNameLength = 100;
private readonly TimeProvider _time;
/// <summary>Creates the service over <paramref name="time"/>; the system clock when null.</summary>
public GreetingService(TimeProvider? time = null)
{
_time = time ?? TimeProvider.System;
}
/// <summary>
/// "Good morning, Ada!" (or afternoon, or evening, by local time). A blank name greets
/// "there".
/// </summary>
/// <exception cref="ArgumentException">The name is longer than <see cref="MaxNameLength"/>.</exception>
public string Greet(string? name)
{
string trimmed = name?.Trim() ?? "";
if(trimmed.Length > MaxNameLength)
{
throw new ArgumentException($"A name can be at most {MaxNameLength} characters.", nameof(name));
}
return $"{PartOfDay()}, {(trimmed.Length == 0 ? "there" : trimmed)}!";
}
/// <summary>Morning before noon, afternoon before six, evening after.</summary>
private string PartOfDay()
{
int hour = _time.GetLocalNow().Hour;
return hour switch
{
< 12 => "Good morning",
< 18 => "Good afternoon",
_ => "Good evening",
};
}
}
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using KofTwentyTwo.AppKit.Logging;
using KofTwentyTwo.AppKit.Settings;
namespace MarkdownViewr.Windows.Core.Greetings;
/// <summary>
/// Presentation state for the greeting screen. It lives in Core, not in the WinUI project,
/// so it is tested headlessly; the window binds to it with x:Bind and holds no logic.
/// </summary>
public sealed partial class GreetingViewModel : ObservableObject
{
private readonly GreetingService _greetings;
private readonly AppSettings _settings;
private readonly SettingsStore<AppSettings> _store;
private readonly IActivityLog _log;
/// <summary>Creates the view model, starting from the name saved last time.</summary>
public GreetingViewModel(GreetingService greetings, AppSettings settings, SettingsStore<AppSettings> store, IActivityLog log)
{
ArgumentNullException.ThrowIfNull(greetings);
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(log);
_greetings = greetings;
_settings = settings;
_store = store;
_log = log;
Name = settings.LastName;
}
/// <summary>The name typed by the user.</summary>
[ObservableProperty]
public partial string Name { get; set; } = "";
/// <summary>The greeting shown, or the reason the name was rejected.</summary>
[ObservableProperty]
public partial string Message { get; set; } = "";
/// <summary>Greets <see cref="Name"/>, remembers it for next time, and logs the event.</summary>
[RelayCommand]
private void Greet()
{
try
{
Message = _greetings.Greet(Name);
}
catch(ArgumentException ex)
{
Message = ex.Message;
_log.Warning($"Greeting rejected: {ex.Message}");
return;
}
_settings.LastName = Name.Trim();
if(!_store.Save(_settings))
{
_log.Warning("Could not save the last greeted name.");
}
_log.Info("Greeted the user.");
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Everything that can be tested without a window lives here (architecture: "the
UI project contains views and framework plumbing only"). Held to 100% line
coverage by the CI gate. -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
<!-- Include the SDK tool dependency in the lock used by single-file CLI publishing. -->
<EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="KofTwentyTwo.AppKit" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="MarkdownViewr.Windows.Core.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,34 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"CommunityToolkit.Mvvm": {
"type": "Direct",
"requested": "[8.4.2, )",
"resolved": "8.4.2",
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
},
"KofTwentyTwo.AppKit": {
"type": "Direct",
"requested": "[0.1.0, )",
"resolved": "0.1.0",
"contentHash": "+ymM8DxPel+C527Cp7lrhhdu0xnGHb6fl4/5vAlzsd0iiyW8u+UiOfQl1paNxA26iQZTbxQ8fMLQsUnaOA8GcQ=="
},
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.296, )",
"resolved": "3.0.296",
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.12, )",
"resolved": "10.0.12",
"contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ=="
}
},
"net10.0/win-arm64": {},
"net10.0/win-x64": {},
"net10.0/win-x86": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<Application
x:Class="MarkdownViewr.Windows.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>
+42
View File
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Logging;
using KofTwentyTwo.AppKit.WinUI;
using Microsoft.UI.Xaml;
namespace MarkdownViewr.Windows;
/// <summary>The application: installs AppKit's crash net and the saved theme, then opens the main window.</summary>
public partial class App : Application
{
private Window? _window;
/// <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);
WinUIShell.ApplyTheme(this, AppIdentity.SettingsStore.Load().ThemeKind);
}
/// <summary>The app-wide activity log.</summary>
internal static IActivityLog Log { get; } = AppIdentity.CreateLog();
/// <summary>Logs the start and opens the main window.</summary>
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
Log.Info($"{AppIdentity.Info.DisplayName} started.");
_window = new MainWindow();
_window.Activate();
}
}
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<!-- Generated by AppKit BrandTool. Transparent outside the rounded tile. -->
<defs>
<linearGradient id="tile" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#0F766E" />
<stop offset="1" stop-color="#134E4A" />
</linearGradient>
</defs>
<rect x="48" y="48" width="416" height="416" rx="104" fill="url(#tile)" />
<path d="M 200 152 L 312 256 L 200 360" fill="none" stroke="#FFFFFF" stroke-width="56" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+51
View File
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<Window
x:Class="MarkdownViewr.Windows.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Window.SystemBackdrop>
<MicaBackdrop />
</Window.SystemBackdrop>
<Grid x:Name="RootGrid">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<MenuBar>
<MenuBarItem Title="File">
<MenuFlyoutItem x:Name="ExitMenuItem" 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" />
<ToggleMenuFlyoutItem x:Name="UpdateCheckToggle" Text="Check for updates at startup" Click="UpdateCheck_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 MarkdownViewr" Click="About_Click" />
</MenuBarItem>
</MenuBar>
<!-- The example feature, bound to GreetingViewModel in MarkdownViewr.Windows.Core. Replace it with
the app's real content. -->
<StackPanel Grid.Row="1" Spacing="12" Padding="32" MaxWidth="560" HorizontalAlignment="Left">
<TextBlock Text="MarkdownViewr" Style="{ThemeResource TitleTextBlockStyle}" />
<TextBlock TextWrapping="Wrap" Text="A Windows Markdown viewer with live reload, themes and document navigation." />
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox x:Name="NameBox" Width="260" PlaceholderText="Your name" Text="{x:Bind ViewModel.Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<Button x:Name="GreetButton" Content="Greet" Command="{x:Bind ViewModel.GreetCommand}" />
</StackPanel>
<TextBlock x:Name="GreetingText" Text="{x:Bind ViewModel.Message, Mode=OneWay}" Style="{ThemeResource SubtitleTextBlockStyle}" />
<Button x:Name="ThrowButton" Content="Throw an unhandled exception" Click="Throw_Click" />
<TextBlock x:Name="StatusText" Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,139 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Settings;
using KofTwentyTwo.AppKit.Updates;
using KofTwentyTwo.AppKit.WinUI;
using Microsoft.UI.Xaml;
namespace MarkdownViewr.Windows;
/// <summary>
/// The main window: the AppKit shell (splash, About, activity log, update checks, theme)
/// around the app's content. Handlers here only route to AppKit or to view models in Core.
/// </summary>
public sealed partial class MainWindow : Window
{
private readonly AppSettings _settings = AppIdentity.SettingsStore.Load();
private readonly UpdateCoordinator _updates;
/// <summary>Sizes and brands the window, creates the view model, shows the splash, and schedules the quiet startup update check.</summary>
public MainWindow()
{
ViewModel = new GreetingViewModel(new GreetingService(), _settings, AppIdentity.SettingsStore, App.Log);
InitializeComponent();
Title = AppIdentity.Info.DisplayName;
this.ResizeForDpi(900, 600);
this.SetMinimumSize(640, 420);
this.SetAppIcon(AppIdentity.Info);
_updates = new UpdateCoordinator(
new VelopackUpdateService(AppIdentity.Info.RepositoryUrl!),
new ContentDialogPrompter(() => Content.XamlRoot),
AppIdentity.Info.DisplayName,
App.Log);
SplashToggle.IsChecked = _settings.ShowSplashScreen;
UpdateCheckToggle.IsChecked = _settings.CheckForUpdatesOnStartup;
(_settings.ThemeKind switch { AppTheme.Light => ThemeLight, AppTheme.Dark => ThemeDark, _ => ThemeSystem }).IsChecked = true;
SplashOverlay.ShowOver(RootGrid, AppIdentity.Info, _settings);
Closed += (_, _) => LogWindow.CloseCurrent(); // a log-only process would linger otherwise
if(_settings.CheckForUpdatesOnStartup)
{
// The constructor runs on the UI thread, so the check resumes there to show dialogs.
_ = CheckForUpdatesAfterStartupAsync();
}
}
/// <summary>The greeting screen's state; x:Bind targets it.</summary>
public GreetingViewModel ViewModel { get; }
/// <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();
}
/// <summary>File, Exit.</summary>
private void Exit_Click(object sender, RoutedEventArgs e) => Close();
/// <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>View, Check for updates at startup: saves the preference.</summary>
private void UpdateCheck_Click(object sender, RoutedEventArgs e)
{
_settings.CheckForUpdatesOnStartup = UpdateCheckToggle.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, AppIdentity.Info);
/// <summary>Help, About: shows the About dialog.</summary>
private async void About_Click(object sender, RoutedEventArgs e) => await AboutDialog.ShowAsync(AppIdentity.Info, Content.XamlRoot);
/// <summary>Throws on purpose: proves the crash net logs the exception and the app keeps running. Remove it from a real app.</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 exception from the template.");
}
/// <summary>Saves the settings and reports a failed save in the status line.</summary>
private void Save()
{
if(!AppIdentity.SettingsStore.Save(_settings))
{
StatusText.Text = "Could not save settings.";
}
}
}
@@ -0,0 +1,65 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- The WinUI 3 shell: views, dialogs, and startup plumbing only; everything testable
lives in MarkdownViewr.Windows.Core. Unpackaged and self-contained: the shape Velopack ships. -->
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<RootNamespace>MarkdownViewr.Windows</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\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>
<!-- The splash and About render the SVG; the window and taskbar use the .ico. Both must
reach the build and publish output, since the unpackaged app has no MSIX step. -->
<Content Include="Assets\Brand\app-icon.svg" CopyToOutputDirectory="PreserveNewest" />
<Content Include="Assets\Brand\app.ico" CopyToOutputDirectory="PreserveNewest" />
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="KofTwentyTwo.AppKit.Updates" />
<PackageReference Include="KofTwentyTwo.AppKit.WinUI" />
<PackageReference Include="Microsoft.WindowsAppSDK" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MarkdownViewr.Windows.Core\MarkdownViewr.Windows.Core.csproj" />
</ItemGroup>
<ItemGroup>
<!-- The app's own namespaces are global usings: their sort position against other
usings depends on the app's name, which the template replaces. -->
<Using Include="MarkdownViewr.Windows.Core" />
<Using Include="MarkdownViewr.Windows.Core.Greetings" />
</ItemGroup>
<!-- Unpackaged WinUI publishes omit the app PRI/XBF items (WindowsAppSDK#6720).
Add build-produced resources before the SDK computes/copies publish files. -->
<Target Name="IncludeUnpackagedXamlResourcesInPublish" BeforeTargets="ComputeResolvedFilesToPublishList"
Condition="'$(WindowsPackageType)' == 'None'">
<Error Condition="!Exists('$(TargetDir)$(TargetName).pri')" Text="The app PRI resource index is missing from the build output." />
<ItemGroup>
<ResolvedFileToPublish Include="$(TargetDir)$(TargetName).pri">
<RelativePath>$(TargetName).pri</RelativePath>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</ResolvedFileToPublish>
<_AppXamlPublishFiles Include="$(TargetDir)**\*.xbf" />
<ResolvedFileToPublish Include="@(_AppXamlPublishFiles)">
<RelativePath>%(_AppXamlPublishFiles.RecursiveDir)%(_AppXamlPublishFiles.Filename)%(_AppXamlPublishFiles.Extension)</RelativePath>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
</Project>
+33
View File
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Microsoft.UI.Dispatching;
using Velopack;
namespace MarkdownViewr.Windows;
/// <summary>
/// Custom entry point (DISABLE_XAML_GENERATED_MAIN) so Velopack handles its install,
/// update, and uninstall hooks before any UI exists.
/// </summary>
public static class Program
{
/// <summary>Runs Velopack's hooks first (they may exit the process), then starts the WinUI application.</summary>
[STAThread]
private static void Main()
{
// Keep this direct call: vpk verifies the entry assembly before creating installers.
VelopackApp.Build().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();
});
}
}
+17
View File
@@ -0,0 +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="MarkdownViewr.Windows.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>
<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,268 @@
{
"version": 2,
"dependencies": {
"net10.0-windows10.0.19041": {
"KofTwentyTwo.AppKit.Updates": {
"type": "Direct",
"requested": "[0.1.0, )",
"resolved": "0.1.0",
"contentHash": "81vbwwOKoMtF7O423F8LYUTg8KjcyKQXqfqC6oWnRsQfDNQMgh+vaSKjmX6qmgvOAIY0dVsmu55TEpeRaJMGDw==",
"dependencies": {
"KofTwentyTwo.AppKit": "0.1.0",
"Velopack": "1.2.161"
}
},
"KofTwentyTwo.AppKit.WinUI": {
"type": "Direct",
"requested": "[0.1.0, )",
"resolved": "0.1.0",
"contentHash": "TgIz7wROrZJoopQVZPay/PmDIyK6p8RrWRxUiOXlLUFMehKLwFAABm7WaUH+OOn6Jac6LjsNoWRW8CVGoWl9hg==",
"dependencies": {
"KofTwentyTwo.AppKit": "0.1.0",
"KofTwentyTwo.AppKit.Updates": "0.1.0",
"Microsoft.WindowsAppSDK": "2.5.1",
"Velopack": "1.2.161"
}
},
"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=="
},
"Velopack": {
"type": "Transitive",
"resolved": "1.2.161",
"contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
},
"markdownviewr.windows.core": {
"type": "Project",
"dependencies": {
"CommunityToolkit.Mvvm": "[8.4.2, )",
"KofTwentyTwo.AppKit": "[0.1.0, )"
}
},
"CommunityToolkit.Mvvm": {
"type": "CentralTransitive",
"requested": "[8.4.2, )",
"resolved": "8.4.2",
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
},
"KofTwentyTwo.AppKit": {
"type": "CentralTransitive",
"requested": "[0.1.0, )",
"resolved": "0.1.0",
"contentHash": "+ymM8DxPel+C527Cp7lrhhdu0xnGHb6fl4/5vAlzsd0iiyW8u+UiOfQl1paNxA26iQZTbxQ8fMLQsUnaOA8GcQ=="
}
},
"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"
}
}
}
}
}
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Logging;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>Tests of the app's identity and the services derived from it.</summary>
public class AppIdentityTests
{
/// <summary>Info: the id and display name are the app's.</summary>
[Fact]
public void Info_IdentifiesTheApp()
{
Assert.Equal("markdownviewr-windows", AppIdentity.Info.Id);
Assert.Equal("MarkdownViewr", AppIdentity.Info.DisplayName);
Assert.EndsWith("_DATA_DIR", AppIdentity.Info.DataDirectoryVariable, StringComparison.Ordinal);
Assert.NotNull(AppIdentity.Info.RepositoryUrl);
Assert.NotEmpty(AppIdentity.Info.Attributions);
}
/// <summary>Paths and SettingsStore: settings live in the app's data folder.</summary>
[Fact]
public void SettingsStore_UsesTheAppsSettingsFile()
{
Assert.Equal(AppIdentity.Paths.SettingsFile, AppIdentity.SettingsStore.Path);
Assert.EndsWith("settings.json", AppIdentity.SettingsStore.Path, StringComparison.Ordinal);
}
/// <summary>CreateLog: writes daily files in the app's logs folder, named after the id.</summary>
[Fact]
public void CreateLog_WritesToTheAppsLogsFolder()
{
IActivityLog log = AppIdentity.CreateLog();
Assert.Equal(AppIdentity.Paths.LogsDirectory, log.LogDirectory);
Assert.StartsWith("markdownviewr-windows-", Path.GetFileName(log.CurrentLogFilePath), StringComparison.Ordinal);
}
}
/// <summary>Tests of the app's settings.</summary>
public class AppSettingsTests
{
/// <summary>Sanitize: a null name from JSON becomes empty, and the shell settings are repaired too.</summary>
[Fact]
public void Sanitize_RepairsNullsAndShellSettings()
{
var settings = new AppSettings { LastName = null!, Theme = "Purple" };
settings.Sanitize();
Assert.Equal("", settings.LastName);
Assert.Equal("System", settings.Theme);
}
/// <summary>Round trip: the name survives a save and a load.</summary>
[Fact]
public void SaveAndLoad_KeepTheName()
{
using var temp = new TempDirectory();
var store = new KofTwentyTwo.AppKit.Settings.SettingsStore<AppSettings>(temp.File("settings.json"), AppSettingsContext.Default.AppSettings);
Assert.True(store.Save(new AppSettings { LastName = "Ada" }));
Assert.Equal("Ada", store.Load().LastName);
}
}
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Logging;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>A clock fixed at one local time, in a UTC zone so local time equals the given time.</summary>
internal sealed class FixedTimeProvider(int hour) : TimeProvider
{
/// <summary>Today at the given hour.</summary>
public override DateTimeOffset GetUtcNow() => new(2026, 3, 15, hour, 30, 0, TimeSpan.Zero);
/// <summary>UTC, so GetLocalNow returns GetUtcNow unchanged.</summary>
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
/// <summary>An activity log that records entries in memory.</summary>
internal sealed class RecordingLog : IActivityLog
{
/// <summary>Entries as "LEVEL message".</summary>
public List<string> Entries { get; } = [];
/// <inheritdoc/>
public string LogDirectory => "";
/// <inheritdoc/>
public string CurrentLogFilePath => "";
/// <inheritdoc/>
public void Info(string message) => Entries.Add("INFO " + message);
/// <inheritdoc/>
public void Warning(string message) => Entries.Add("WARN " + message);
/// <inheritdoc/>
public void Error(string message, Exception? exception = null) => Entries.Add("ERROR " + message);
}
/// <summary>A per-test temporary directory, deleted on dispose.</summary>
internal sealed class TempDirectory : IDisposable
{
/// <summary>Creates the directory.</summary>
public TempDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "markdownviewr-windows-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path);
}
/// <summary>The directory.</summary>
public string Path { get; }
/// <summary>A file path inside the directory.</summary>
public string File(string name) => System.IO.Path.Combine(Path, name);
/// <summary>Deletes the directory; a handle left open by a failed test is left for the OS.</summary>
public void Dispose()
{
try
{
Directory.Delete(Path, recursive: true);
}
catch(IOException)
{
// Cleanup only; the test outcome is already decided.
}
}
}
@@ -0,0 +1,148 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using KofTwentyTwo.AppKit.Settings;
namespace MarkdownViewr.Windows.Core.Tests;
/// <summary>Tests of the example greeting feature's logic.</summary>
public class GreetingServiceTests
{
/// <summary>Greet: the part of the day follows the local hour, including the boundaries.</summary>
[Theory]
[InlineData(0, "Good morning, Ada!")]
[InlineData(11, "Good morning, Ada!")]
[InlineData(12, "Good afternoon, Ada!")]
[InlineData(17, "Good afternoon, Ada!")]
[InlineData(18, "Good evening, Ada!")]
[InlineData(23, "Good evening, Ada!")]
public void Greet_FollowsTheTimeOfDay(int hour, string expected)
{
Assert.Equal(expected, new GreetingService(new FixedTimeProvider(hour)).Greet("Ada"));
}
/// <summary>Greet: a blank or missing name greets "there"; surrounding spaces are trimmed.</summary>
[Theory]
[InlineData(null, "Good morning, there!")]
[InlineData(" ", "Good morning, there!")]
[InlineData(" Ada ", "Good morning, Ada!")]
public void Greet_HandlesBlankAndPaddedNames(string? name, string expected)
{
Assert.Equal(expected, new GreetingService(new FixedTimeProvider(9)).Greet(name));
}
/// <summary>Greet: a name longer than the limit is rejected.</summary>
[Fact]
public void Greet_RejectsOverlongNames()
{
ArgumentException ex = Assert.Throws<ArgumentException>(() => new GreetingService().Greet(new string('x', GreetingService.MaxNameLength + 1)));
Assert.Equal("name", ex.ParamName);
}
}
/// <summary>Tests of the greeting screen's view model.</summary>
public sealed class GreetingViewModelTests : IDisposable
{
private readonly TempDirectory _temp = new();
private readonly RecordingLog _log = new();
/// <summary>Deletes the temporary settings folder.</summary>
public void Dispose() => _temp.Dispose();
/// <summary>A view model over a settings store in the temporary folder.</summary>
private GreetingViewModel Create(AppSettings settings, string? settingsPath = null)
{
var store = new SettingsStore<AppSettings>(settingsPath ?? _temp.File("settings.json"), AppSettingsContext.Default.AppSettings);
return new GreetingViewModel(new GreetingService(new FixedTimeProvider(20)), settings, store, _log);
}
/// <summary>Construction: starts from the name saved last time.</summary>
[Fact]
public void StartsWithTheSavedName()
{
Assert.Equal("Grace", Create(new AppSettings { LastName = "Grace" }).Name);
}
/// <summary>Greet: shows the greeting, saves the trimmed name, and logs it.</summary>
[Fact]
public void Greet_ShowsSavesAndLogs()
{
var settings = new AppSettings();
GreetingViewModel model = Create(settings);
var changed = new List<string?>();
model.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
model.Name = " Ada ";
model.GreetCommand.Execute(null);
Assert.Equal("Good evening, Ada!", model.Message);
Assert.Equal("Ada", settings.LastName);
Assert.Contains(changed, name => string.Equals(name, nameof(GreetingViewModel.Message), StringComparison.Ordinal));
Assert.Equal("Ada", new SettingsStore<AppSettings>(_temp.File("settings.json"), AppSettingsContext.Default.AppSettings).Load().LastName);
Assert.Equal(["INFO Greeted the user."], _log.Entries);
}
/// <summary>Greet: an overlong name shows the reason, warns, and saves nothing.</summary>
[Fact]
public void Greet_RejectsAndWarns()
{
var settings = new AppSettings { LastName = "Kept" };
GreetingViewModel model = Create(settings);
model.Name = new string('x', GreetingService.MaxNameLength + 1);
model.GreetCommand.Execute(null);
Assert.StartsWith("A name can be at most", model.Message, StringComparison.Ordinal);
Assert.Equal("Kept", settings.LastName);
Assert.StartsWith("WARN Greeting rejected", Assert.Single(_log.Entries), StringComparison.Ordinal);
}
/// <summary>Greet: a failed settings save is logged, and the greeting still shows.</summary>
[Fact]
public void Greet_LogsAFailedSave()
{
string notADirectory = _temp.File("file");
File.WriteAllText(notADirectory, "");
GreetingViewModel model = Create(new AppSettings(), Path.Combine(notADirectory, "settings.json"));
model.Name = "Ada";
model.GreetCommand.Execute(null);
Assert.Equal("Good evening, Ada!", model.Message);
Assert.Equal(["WARN Could not save the last greeted name.", "INFO Greeted the user."], _log.Entries);
}
/// <summary>Construction: every dependency is required.</summary>
[Fact]
public void Constructor_ValidatesArguments()
{
var store = new SettingsStore<AppSettings>(_temp.File("settings.json"), AppSettingsContext.Default.AppSettings);
var service = new GreetingService();
var settings = new AppSettings();
Assert.Throws<ArgumentNullException>(() => new GreetingViewModel(null!, settings, store, _log));
Assert.Throws<ArgumentNullException>(() => new GreetingViewModel(service, null!, store, _log));
Assert.Throws<ArgumentNullException>(() => new GreetingViewModel(service, settings, null!, _log));
Assert.Throws<ArgumentNullException>(() => new GreetingViewModel(service, settings, store, null!));
}
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<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>
<ProjectReference Include="..\..\src\MarkdownViewr.Windows.Core\MarkdownViewr.Windows.Core.csproj" />
</ItemGroup>
<ItemGroup>
<!-- The app's own namespaces are global usings: their sort position against other
usings depends on the app's name, which the template replaces. -->
<Using Include="MarkdownViewr.Windows.Core.Greetings" />
</ItemGroup>
</Project>
@@ -0,0 +1,123 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"coverlet.collector": {
"type": "Direct",
"requested": "[10.1.0, )",
"resolved": "10.1.0",
"contentHash": "SlYCIHnBYxINSUpR75SiM5JEmnx11pqT+Ac/C/fUqTTA0dLPl9eYzmFXKKuxmQhL3ZdTQl4PBD3YdAxAauaKLQ=="
},
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.296, )",
"resolved": "3.0.296",
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
},
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[18.10.1, )",
"resolved": "18.10.1",
"contentHash": "SxCFvJE/2ltUQgCgv6+Ixagohw4sZc9hM7Wid2foaNM7paBscktoHLoRDz91fW8wbHG87Rg9Ku59/8PXumqUrA==",
"dependencies": {
"Microsoft.CodeCoverage": "18.10.1",
"Microsoft.TestPlatform.TestHost": "18.10.1"
}
},
"xunit": {
"type": "Direct",
"requested": "[2.9.3, )",
"resolved": "2.9.3",
"contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
"dependencies": {
"xunit.analyzers": "1.18.0",
"xunit.assert": "2.9.3",
"xunit.core": "[2.9.3]"
}
},
"xunit.runner.visualstudio": {
"type": "Direct",
"requested": "[4.0.0, )",
"resolved": "4.0.0",
"contentHash": "kzLFyBYUnoidpGOXvpNOfIo8C92gVgVR6X8ncHwhcrDYugiOut5rrhDkr0L3cWHd7J2pKXf6LgaeXoBBDlx1ag=="
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "18.10.1",
"contentHash": "tV7tCyp/t+DuwQugLkCeUN7NBFFB4fEGRPr0gkMQ2nQgAvQQjmGlZZi1dsLzbyoe/A0mbnKybmkIh8qhT9p4Zw=="
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "18.10.1",
"contentHash": "o+PuLDRVp1iwiVAPBADZItXgm9Ck23XtPhE4kZtZVi0uYnMiiz1Dx9Cjh75kJ+ijfqnORL3VOTOaQVKi0f/ixg=="
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
"resolved": "18.10.1",
"contentHash": "KYz2omqkoz9E2mWa4fdgT+Ycie1wi5968EmyJlusjlp87RxxtA2FvDm8tcBEYIDzLnkWGnsKjE4d13Z7/rxkQA==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "18.10.1"
}
},
"xunit.abstractions": {
"type": "Transitive",
"resolved": "2.0.3",
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.18.0",
"contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
},
"xunit.assert": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
},
"xunit.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]",
"xunit.extensibility.execution": "[2.9.3]"
}
},
"xunit.extensibility.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
"dependencies": {
"xunit.abstractions": "2.0.3"
}
},
"xunit.extensibility.execution": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]"
}
},
"markdownviewr.windows.core": {
"type": "Project",
"dependencies": {
"CommunityToolkit.Mvvm": "[8.4.2, )",
"KofTwentyTwo.AppKit": "[0.1.0, )"
}
},
"CommunityToolkit.Mvvm": {
"type": "CentralTransitive",
"requested": "[8.4.2, )",
"resolved": "8.4.2",
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
},
"KofTwentyTwo.AppKit": {
"type": "CentralTransitive",
"requested": "[0.1.0, )",
"resolved": "0.1.0",
"contentHash": "+ymM8DxPel+C527Cp7lrhhdu0xnGHb6fl4/5vAlzsd0iiyW8u+UiOfQl1paNxA26iQZTbxQ8fMLQsUnaOA8GcQ=="
}
}
}
}
@@ -0,0 +1,383 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Diagnostics;
using FlaUI.Core;
using FlaUI.Core.AutomationElements;
using FlaUI.Core.Conditions;
using FlaUI.Core.Definitions;
using FlaUI.Core.Input;
using FlaUI.Core.Patterns;
using FlaUI.Core.Tools;
using FlaUI.UIA3;
namespace MarkdownViewr.Windows.UiTests;
/// <summary>
/// One launched app plus the UIA3 automation that drives it. Each session gets a fresh
/// temp directory passed as the app's data-folder variable, so tests start with no
/// settings or logs and never touch the real profile. Unless a test asks for the
/// splash, the session pre-writes settings that skip it and the startup update check.
/// </summary>
public sealed class AppSession : IDisposable
{
private static readonly TimeSpan s_defaultTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan s_launchTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan s_pollInterval = TimeSpan.FromMilliseconds(250);
private readonly DpiAwarenessScope _dpi;
/// <summary>Launches the app against a fresh temporary data folder and waits for its main window.</summary>
public AppSession(bool showSplash = false)
{
ExePath = ResolveExePath();
DataDirectory = Path.Combine(Path.GetTempPath(), AppIdentity.Info.Id + "-uitests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(DataDirectory);
File.WriteAllText(
Path.Combine(DataDirectory, "settings.json"),
$$"""{ "ShowSplashScreen": {{(showSplash ? "true" : "false")}}, "CheckForUpdatesOnStartup": false }""");
var startInfo = new ProcessStartInfo(ExePath) { WorkingDirectory = Path.GetDirectoryName(ExePath) ?? "" };
startInfo.Environment[AppIdentity.Info.DataDirectoryVariable] = DataDirectory;
_dpi = new DpiAwarenessScope();
try
{
Automation = new UIA3Automation();
App = Application.Launch(startInfo);
MainWindow = WaitForMainWindow();
}
catch
{
Dispose();
throw;
}
}
public string ExePath { get; }
public string DataDirectory { get; }
public UIA3Automation Automation { get; }
public Application App { get; }
public Window MainWindow { get; }
/// <summary>Observed mouse target and focus, retained in timeout diagnostics.</summary>
public string LastMouseInput { get; private set; } = "No physical input observed.";
/// <summary>Reports whether the most recent control used UIA or physical input.</summary>
public string LastInvocation { get; private set; } = "No control invoked.";
/// <summary>Text of every log file the app wrote so far.</summary>
public string ReadLogs()
{
string logs = Path.Combine(DataDirectory, "logs");
return Directory.Exists(logs)
? string.Concat(Directory.GetFiles(logs, "*.log").Select(ReadShared))
: "";
}
/// <summary>The settings file as the app last saved it.</summary>
public string ReadSettings() => ReadShared(Path.Combine(DataDirectory, "settings.json"));
/// <summary>
/// Finds the first match in the main window or any other top-level window of the
/// app's process (WinUI menu flyouts and secondary windows are separate HWNDs).
/// </summary>
public AutomationElement? FindInApp(Func<ConditionFactory, ConditionBase> condition)
=> MainWindow.FindFirstDescendant(condition)
?? TopLevelWindows().Where(w => !w.Equals(MainWindow)).Select(w => w.FindFirstDescendant(condition)).FirstOrDefault(found => found is not null);
/// <summary>Top-level windows owned by the app's process.</summary>
public AutomationElement[] TopLevelWindows()
=> Automation.GetDesktop().FindAllChildren(cf => cf.ByProcessId(App.ProcessId));
/// <summary>Retries the lookup until it returns an element, or fails naming what was awaited.</summary>
public AutomationElement WaitFor(Func<AutomationElement?> lookup, string description, TimeSpan? timeout = null)
{
TimeSpan effective = timeout ?? s_defaultTimeout;
RetryResult<AutomationElement?> result = Retry.WhileNull(lookup, timeout: effective, interval: s_pollInterval, ignoreException: true);
return result.Result
?? throw new TimeoutException($"Timed out after {effective.TotalSeconds:0}s waiting for {description}. (app exited: {App.HasExited})");
}
/// <summary>Retries until the lookup returns nothing, or fails naming what was expected to disappear.</summary>
public static void WaitUntilGone(Func<AutomationElement?> lookup, string description, TimeSpan? timeout = null)
{
TimeSpan effective = timeout ?? s_defaultTimeout;
if(!Retry.WhileTrue(() => lookup() is not null, timeout: effective, interval: s_pollInterval, ignoreException: true).Success)
{
throw new TimeoutException($"Timed out after {effective.TotalSeconds:0}s waiting for {description} to go away.");
}
}
/// <summary>Waits for the element with the given automation id in any of the app&apos;s windows.</summary>
public AutomationElement WaitForElement(string automationId, TimeSpan? timeout = null)
=> WaitFor(() => FindInApp(cf => cf.ByAutomationId(automationId)), $"element '{automationId}'", timeout);
/// <summary>Waits until no element with the given automation id remains.</summary>
public void WaitForElementGone(string automationId, TimeSpan? timeout = null)
=> WaitUntilGone(() => FindInApp(cf => cf.ByAutomationId(automationId)), $"element '{automationId}'", timeout);
/// <summary>
/// Opens the top-level menu named <paramref name="menuName"/> and invokes the item
/// <paramref name="itemAutomationId"/>. A freshly started app can swallow the first
/// expand (its flyout opens before the menu is ready), so the menu is re-opened
/// until the item appears.
/// </summary>
public void InvokeMenuItem(string menuName, string itemAutomationId)
{
MainWindow.SetForeground();
AutomationElement menu = WaitFor(
() => MainWindow.FindFirstDescendant(cf => cf.ByControlType(ControlType.MenuItem).And(cf.ByName(menuName))),
$"menu '{menuName}'");
AutomationElement? item = null;
for(int attempt = 0; attempt < 4 && item is null; attempt++)
{
if(menu.Patterns.ExpandCollapse.TryGetPattern(out IExpandCollapsePattern? expandCollapse))
{
expandCollapse.Expand();
}
else
{
Invoke(menu);
}
Wait.UntilInputIsProcessed();
item = Retry.WhileNull(() => FindInApp(cf => cf.ByAutomationId(itemAutomationId)), timeout: TimeSpan.FromSeconds(3), interval: s_pollInterval, ignoreException: true).Result;
}
Invoke(item ?? throw new TimeoutException($"Menu item '{itemAutomationId}' never appeared under '{menuName}'. (app exited: {App.HasExited})"));
}
/// <summary>Invokes the button named <paramref name="name"/> (XAML buttons only, not caption buttons).</summary>
public void ClickButton(string name)
=> Invoke(WaitFor(
() => FindInApp(cf => cf.ByControlType(ControlType.Button).And(cf.ByName(name)).And(cf.ByClassName("Button"))),
$"button '{name}'"));
/// <summary>
/// Clicks with the real mouse. Needed where the handler throws: WinUI hands an
/// exception raised during a UIA Invoke back to the automation client instead of
/// raising Application.UnhandledException, which is what a user's click does.
/// </summary>
public void MouseClickElement(string automationId) => ClickWithMouse(WaitForElement(automationId));
/// <summary>Waits for the app to own the clickable point before one physical click.</summary>
private void ClickWithMouse(AutomationElement element)
{
string? automationId = element.Properties.AutomationId.ValueOrDefault;
element.Focus();
Wait.UntilInputIsProcessed();
WaitFor(
() =>
{
MainWindow.SetForeground();
if(!element.TryGetClickablePoint(out System.Drawing.Point point))
{
LastMouseInput = $"Expected PID={App.ProcessId}; target={automationId}; no clickable point.";
return null;
}
AutomationElement hit = Automation.FromPoint(point);
AutomationElement focused = Automation.FocusedElement();
int hitProcess = hit.Properties.ProcessId.ValueOrDefault;
string? hitId = hitProcess == App.ProcessId ? hit.Properties.AutomationId.ValueOrDefault : "outside app";
LastMouseInput = $"Expected PID={App.ProcessId}; target={automationId}; rectangle={element.BoundingRectangle}; point={point}; hit PID={hitProcess}; hit ID={hitId}; focus PID={focused.Properties.ProcessId.ValueOrDefault}; enabled={element.IsEnabled}; offscreen={element.IsOffscreen}.";
return hitProcess == App.ProcessId && element.IsEnabled && !element.IsOffscreen ? element : null;
},
$"an unobscured mouse target '{automationId}'");
element.Click();
Wait.UntilInputIsProcessed();
LastMouseInput += $" Mouse after={Mouse.Position}; hit PID={Automation.FromPoint(Mouse.Position).Properties.ProcessId.ValueOrDefault}.";
}
/// <summary>Invokes the element carrying <paramref name="automationId"/> (a button, for example).</summary>
public void ClickElement(string automationId) => Invoke(WaitForElement(automationId));
/// <summary>
/// Replaces the text of the element carrying <paramref name="automationId"/>: through the
/// UI Automation Value pattern (no focus needed), or by typing when the pattern is absent.
/// </summary>
public void EnterText(string automationId, string text)
{
MainWindow.SetForeground();
AutomationElement element = WaitForElement(automationId);
if(element.Patterns.Value.TryGetPattern(out IValuePattern? value) && !value.IsReadOnly.ValueOrDefault)
{
value.SetValue(text);
}
else
{
element.Focus();
Wait.UntilInputIsProcessed();
Keyboard.Type(text);
}
Wait.UntilInputIsProcessed();
}
/// <summary>Invokes an element through UI Automation, falling back to a mouse click.</summary>
private void Invoke(AutomationElement element)
{
MainWindow.SetForeground();
if(element.Patterns.Invoke.TryGetPattern(out IInvokePattern? invoke))
{
LastInvocation = $"UIA Invoke: {element.Properties.AutomationId.ValueOrDefault}.";
invoke.Invoke();
}
else
{
LastInvocation = $"Mouse fallback: {element.Properties.AutomationId.ValueOrDefault}.";
ClickWithMouse(element);
}
Wait.UntilInputIsProcessed();
}
/// <summary>Waits for the app&apos;s main window, failing fast if the process exits.</summary>
private Window WaitForMainWindow()
{
RetryResult<Window?> result = Retry.WhileNull(
() =>
{
if(App.HasExited)
{
throw new InvalidOperationException($"The app exited while starting (exit code {App.ExitCode}). Exe: {ExePath}");
}
try
{
return App.GetMainWindow(Automation, TimeSpan.FromMilliseconds(500));
}
catch
{
return null;
}
},
timeout: s_launchTimeout,
interval: s_pollInterval);
return result.Result ?? throw new TimeoutException($"The main window did not appear within {s_launchTimeout.TotalSeconds:0}s. Exe: {ExePath}");
}
/// <summary>Reads a file the app may still be writing, without locking it.</summary>
private static string ReadShared(string path)
{
if(!File.Exists(path))
{
return "";
}
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
return new StreamReader(stream).ReadToEnd();
}
/// <summary>The exe to drive: K22_UITEST_EXE when set, else the repo's newest Debug build.</summary>
private static string ResolveExePath()
{
const string Variable = "K22_UITEST_EXE";
if(Environment.GetEnvironmentVariable(Variable) is { Length: > 0 } fromEnv)
{
return File.Exists(fromEnv) ? fromEnv : throw new FileNotFoundException($"{Variable} points at '{fromEnv}', which does not exist.", fromEnv);
}
for(DirectoryInfo? current = new(AppContext.BaseDirectory); current is not null; current = current.Parent)
{
if(!File.Exists(Path.Combine(current.FullName, "MarkdownViewr.Windows.slnx")))
{
continue;
}
string bin = Path.Combine(current.FullName, "src", "MarkdownViewr.Windows", "bin");
string? found = Directory.Exists(bin)
? Directory.EnumerateFiles(bin, "MarkdownViewr.Windows.exe", SearchOption.AllDirectories)
.Where(path => path.Contains($"{Path.DirectorySeparatorChar}Debug{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
.OrderByDescending(File.GetLastWriteTimeUtc)
.FirstOrDefault()
: null;
return found ?? throw new FileNotFoundException($"MarkdownViewr.Windows.exe not found under '{bin}'. Build first: dotnet build MarkdownViewr.Windows.slnx -p:Platform=x64");
}
throw new FileNotFoundException($"No MarkdownViewr.Windows.slnx above '{AppContext.BaseDirectory}'. Set {Variable}.");
}
/// <summary>Closes the app (killing it if needed), then disposes the automation and deletes the data folder.</summary>
public void Dispose()
{
try
{
try
{
App?.Close();
}
catch
{
// Already gone; the kill below double-checks.
}
try
{
if(App is { HasExited: false })
{
App.Kill();
}
}
catch
{
// Best effort.
}
App?.Dispose();
Automation?.Dispose();
try
{
Directory.Delete(DataDirectory, recursive: true);
}
catch
{
// A log handle can linger briefly after exit; the temp cleaner gets it.
}
}
finally
{
_dpi.Dispose();
}
}
}
@@ -0,0 +1,162 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using FlaUI.Core.AutomationElements;
using Xunit.Abstractions;
namespace MarkdownViewr.Windows.UiTests;
/// <summary>
/// End-to-end tests of the real app: the AppKit shell pieces and the example feature.
/// UI tests share one desktop, so they never run in parallel.
/// </summary>
[Collection("Desktop")]
public class AppTests
{
private readonly ITestOutputHelper _output;
/// <summary>Records input observations in both passing and failing test results.</summary>
public AppTests(ITestOutputHelper output) => _output = output;
/// <summary>Splash: shows, then dismisses itself.</summary>
[Fact]
public void Splash_ShowsThenDismissesItself()
{
using var session = new AppSession(showSplash: true);
session.WaitForElement("AppKitSplash");
session.WaitForElementGone("AppKitSplash", TimeSpan.FromSeconds(15));
}
/// <summary>About: shows the build version.</summary>
[Fact]
public void About_ShowsTheBuildVersion()
{
using var session = new AppSession();
session.InvokeMenuItem("Help", "AboutMenuItem");
AutomationElement version = session.WaitForElement("AboutVersionText");
Assert.StartsWith("Version ", version.Name, StringComparison.Ordinal);
session.ClickButton("Close");
session.WaitForElementGone("AboutVersionText");
}
/// <summary>Crash net: an unhandled exception is logged and the app keeps running.</summary>
[Fact]
public void UnhandledException_IsLoggedAndTheAppSurvives()
{
using var session = new AppSession();
try
{
session.MouseClickElement("ThrowButton");
_output.WriteLine(session.LastMouseInput);
session.WaitFor(
() => session.ReadLogs().Contains("Deliberately unhandled exception from the template", StringComparison.Ordinal) ? session.MainWindow : null,
"the crash net's log entry");
}
catch(TimeoutException exception)
{
AutomationElement? status = session.FindInApp(cf => cf.ByAutomationId("StatusText"));
throw new TimeoutException($"{exception.Message} {session.LastMouseInput} Observed status: '{status?.Properties.Name.ValueOrDefault}'. App logs: {session.ReadLogs()}", exception);
}
Assert.False(session.App.HasExited);
Assert.Contains("Unhandled UI exception", session.ReadLogs(), StringComparison.Ordinal);
}
/// <summary>Activity log: opens a window showing the log.</summary>
[Fact]
public void ActivityLog_OpensAWindowShowingTheLog()
{
using var session = new AppSession();
session.InvokeMenuItem("Help", "ActivityLogMenuItem");
string title = AppIdentity.Info.DisplayName + " — Activity log";
session.WaitFor(
() => session.TopLevelWindows().FirstOrDefault(window => string.Equals(window.Name, title, StringComparison.Ordinal)),
"the activity log window");
}
/// <summary>Theme: the choice is saved to settings.json.</summary>
[Fact]
public void ThemeChoice_IsSaved()
{
using var session = new AppSession();
session.InvokeMenuItem("View", "ThemeDark");
_output.WriteLine(session.LastInvocation);
_output.WriteLine(session.LastMouseInput);
try
{
session.WaitFor(
() => session.ReadSettings().Contains("\"Theme\": \"Dark\"", StringComparison.Ordinal) ? session.MainWindow : null,
"settings.json to record the dark theme");
}
catch(TimeoutException exception)
{
throw new TimeoutException($"{exception.Message} {session.LastInvocation} {session.LastMouseInput} Settings: {session.ReadSettings()}. App logs: {session.ReadLogs()}", exception);
}
}
/// <summary>Updates: a build that was not installed by Velopack says updates need an install.</summary>
[Fact]
public void CheckForUpdates_InALooseBuild_SaysUpdatesNeedAnInstall()
{
using var session = new AppSession();
session.InvokeMenuItem("Help", "CheckForUpdatesMenuItem");
session.WaitFor(
() => session.FindInApp(cf => cf.ByName("Updates are only available in installed builds.")),
"the not-installed message");
session.ClickButton("OK");
}
/// <summary>Example feature: greeting a name shows the greeting and saves the name.</summary>
[Fact]
public void Greet_ShowsTheGreetingAndSavesTheName()
{
using var session = new AppSession();
session.EnterText("NameBox", "Ada");
session.ClickElement("GreetButton");
string? observedGreeting = null;
try
{
session.WaitFor(
() =>
{
AutomationElement? text = session.FindInApp(cf => cf.ByAutomationId("GreetingText"));
observedGreeting = text?.Properties.Name.ValueOrDefault;
return observedGreeting?.EndsWith(", Ada!", StringComparison.Ordinal) == true ? text : null;
},
"the greeting");
}
catch(TimeoutException exception)
{
throw new TimeoutException($"{exception.Message} Observed greeting: '{observedGreeting}'. App logs: {session.ReadLogs()}", exception);
}
session.WaitFor(
() => session.ReadSettings().Contains("\"LastName\": \"Ada\"", StringComparison.Ordinal) ? session.MainWindow : null,
"settings.json to record the name");
}
}
/// <summary>UI tests share one desktop, so the collection never runs in parallel.</summary>
[CollectionDefinition("Desktop", DisableParallelization = true)]
public class DesktopCollectionDefinition
{
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.ComponentModel;
using System.Runtime.InteropServices;
namespace MarkdownViewr.Windows.UiTests;
/// <summary>Uses physical screen coordinates for input and restores the synchronous test thread afterwards.</summary>
internal sealed class DpiAwarenessScope : IDisposable
{
private readonly nint _previous;
/// <summary>Disables DPI virtualization on this thread with per-monitor awareness v2.</summary>
public DpiAwarenessScope()
{
_previous = SetThreadDpiAwarenessContext(-4);
if(_previous == 0)
{
throw new Win32Exception(Marshal.GetLastPInvokeError());
}
}
/// <summary>Restores the context on the thread that ran the synchronous UI test.</summary>
public void Dispose() => SetThreadDpiAwarenessContext(_previous);
/// <summary>Overrides the current Windows thread's DPI awareness.</summary>
[DllImport("user32.dll", ExactSpelling = true, SetLastError = true)]
private static extern nint SetThreadDpiAwarenessContext(nint dpiContext);
}
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Out-of-process UI tests: FlaUI drives the built app over UI Automation, so this
project references only MarkdownViewr.Windows.Core (for the app's identity), never the WinUI
project. Build the solution first (dotnet build MarkdownViewr.Windows.slnx -p:Platform=x64), or
point K22_UITEST_EXE at the exe to test. -->
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FlaUI.UIA3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MarkdownViewr.Windows.Core\MarkdownViewr.Windows.Core.csproj" />
</ItemGroup>
<ItemGroup>
<!-- The app's own namespaces are global usings: their sort position against other
usings depends on the app's name, which the template replaces. -->
<Using Include="MarkdownViewr.Windows.Core" />
</ItemGroup>
</Project>
@@ -0,0 +1,209 @@
{
"version": 2,
"dependencies": {
"net10.0-windows7.0": {
"FlaUI.UIA3": {
"type": "Direct",
"requested": "[5.0.0, )",
"resolved": "5.0.0",
"contentHash": "s1Yy1RsRJ0OXvmtxQlNJd0cu4sfvN3PFFu6skBGK8VlwPl/b38E0V5kCbo2gvkuhRIu4Itfxjdo7DUMF7qJw4Q==",
"dependencies": {
"FlaUI.Core": "5.0.0",
"Interop.UIAutomationClient": "10.19041.0"
}
},
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.296, )",
"resolved": "3.0.296",
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
},
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[18.10.1, )",
"resolved": "18.10.1",
"contentHash": "SxCFvJE/2ltUQgCgv6+Ixagohw4sZc9hM7Wid2foaNM7paBscktoHLoRDz91fW8wbHG87Rg9Ku59/8PXumqUrA==",
"dependencies": {
"Microsoft.CodeCoverage": "18.10.1",
"Microsoft.TestPlatform.TestHost": "18.10.1"
}
},
"xunit": {
"type": "Direct",
"requested": "[2.9.3, )",
"resolved": "2.9.3",
"contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
"dependencies": {
"xunit.analyzers": "1.18.0",
"xunit.assert": "2.9.3",
"xunit.core": "[2.9.3]"
}
},
"xunit.runner.visualstudio": {
"type": "Direct",
"requested": "[4.0.0, )",
"resolved": "4.0.0",
"contentHash": "kzLFyBYUnoidpGOXvpNOfIo8C92gVgVR6X8ncHwhcrDYugiOut5rrhDkr0L3cWHd7J2pKXf6LgaeXoBBDlx1ag=="
},
"FlaUI.Core": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "VWWYqwjsYkii2BrUsz5+bpXEp27NherKhLt19hTxHwJP4KrfyZoeo1FdBov4ZTh655wZAKDGIjR7NJLDesuzeg==",
"dependencies": {
"System.Diagnostics.PerformanceCounter": "8.0.1",
"System.Drawing.Common": "8.0.10",
"System.Management": "8.0.0",
"System.Security.Permissions": "8.0.0"
}
},
"Interop.UIAutomationClient": {
"type": "Transitive",
"resolved": "10.19041.0",
"contentHash": "Me5vKNZ1JSg6txTsPQXaWx7fVNh+fmcUPPwtDKJtqvbMM0N8aoa/l/QPolkJIRnasCabCNt4v2zG4p1Q9HU98A=="
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "18.10.1",
"contentHash": "tV7tCyp/t+DuwQugLkCeUN7NBFFB4fEGRPr0gkMQ2nQgAvQQjmGlZZi1dsLzbyoe/A0mbnKybmkIh8qhT9p4Zw=="
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "18.10.1",
"contentHash": "o+PuLDRVp1iwiVAPBADZItXgm9Ck23XtPhE4kZtZVi0uYnMiiz1Dx9Cjh75kJ+ijfqnORL3VOTOaQVKi0f/ixg=="
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
"resolved": "18.10.1",
"contentHash": "KYz2omqkoz9E2mWa4fdgT+Ycie1wi5968EmyJlusjlp87RxxtA2FvDm8tcBEYIDzLnkWGnsKjE4d13Z7/rxkQA==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "18.10.1"
}
},
"Microsoft.Win32.SystemEvents": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "9opKRyOKMCi2xJ7Bj7kxtZ1r9vbzosMvRrdEhVhDz8j8MoBGgB+WmC94yH839NPH+BclAjtQ/pyagvi/8gDLkw=="
},
"System.CodeDom": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "WTlRjL6KWIMr/pAaq3rYqh0TJlzpouaQ/W1eelssHgtlwHAH25jXTkUphTYx9HaIIf7XA6qs/0+YhtLEQRkJ+Q=="
},
"System.Configuration.ConfigurationManager": {
"type": "Transitive",
"resolved": "8.0.1",
"contentHash": "gPYFPDyohW2gXNhdQRSjtmeS6FymL2crg4Sral1wtvEJ7DUqFCDWDVbbLobASbzxfic8U1hQEdC7hmg9LHncMw==",
"dependencies": {
"System.Diagnostics.EventLog": "8.0.1",
"System.Security.Cryptography.ProtectedData": "8.0.0"
}
},
"System.Diagnostics.EventLog": {
"type": "Transitive",
"resolved": "8.0.1",
"contentHash": "n1ZP7NM2Gkn/MgD8+eOT5MulMj6wfeQMNS2Pizvq5GHCZfjlFMXV2irQlQmJhwA2VABC57M0auudO89Iu2uRLg=="
},
"System.Diagnostics.PerformanceCounter": {
"type": "Transitive",
"resolved": "8.0.1",
"contentHash": "9RfEDiEjlUADeThs8IPdDVTXSnPRSqjfgTQJALpmGFPKC0k2mbdufOXnb/9JZ4I0TkmxOfy3VTJxrHOJSs8cXg==",
"dependencies": {
"System.Configuration.ConfigurationManager": "8.0.1"
}
},
"System.Drawing.Common": {
"type": "Transitive",
"resolved": "8.0.10",
"contentHash": "MdajRp3P+FOlThgY6FBjAqnmLiVl5t2yWEC/2AsDMqx1zYbJG3G5TnscFBQ4obqcaGqvN5UnhQHSFaJFG2HftQ==",
"dependencies": {
"Microsoft.Win32.SystemEvents": "8.0.0"
}
},
"System.Management": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "jrK22i5LRzxZCfGb+tGmke2VH7oE0DvcDlJ1HAKYU8cPmD8XnpUT0bYn2Gy98GEhGjtfbR/sxKTVb+dE770pfA==",
"dependencies": {
"System.CodeDom": "8.0.0"
}
},
"System.Security.Cryptography.ProtectedData": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "+TUFINV2q2ifyXauQXRwy4CiBhqvDEDZeVJU7qfxya4aRYOKzVBpN+4acx25VcPB9ywUN6C0n8drWl110PhZEg=="
},
"System.Security.Permissions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "v/BBylw7XevuAsHXoX9dDUUfmBIcUf7Lkz8K3ZXIKz3YRKpw8YftpSir4n4e/jDTKFoaK37AsC3xnk+GNFI1Ow==",
"dependencies": {
"System.Windows.Extensions": "8.0.0"
}
},
"System.Windows.Extensions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "Obg3a90MkOw9mYKxrardLpY2u0axDMrSmy4JCdq2cYbelM2cUwmUir5Bomvd1yxmPL9h5LVHU1tuKBZpUjfASg=="
},
"xunit.abstractions": {
"type": "Transitive",
"resolved": "2.0.3",
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.18.0",
"contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
},
"xunit.assert": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
},
"xunit.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]",
"xunit.extensibility.execution": "[2.9.3]"
}
},
"xunit.extensibility.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
"dependencies": {
"xunit.abstractions": "2.0.3"
}
},
"xunit.extensibility.execution": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]"
}
},
"markdownviewr.windows.core": {
"type": "Project",
"dependencies": {
"CommunityToolkit.Mvvm": "[8.4.2, )",
"KofTwentyTwo.AppKit": "[0.1.0, )"
}
},
"CommunityToolkit.Mvvm": {
"type": "CentralTransitive",
"requested": "[8.4.2, )",
"resolved": "8.4.2",
"contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
},
"KofTwentyTwo.AppKit": {
"type": "CentralTransitive",
"requested": "[0.1.0, )",
"resolved": "0.1.0",
"contentHash": "+ymM8DxPel+C527Cp7lrhhdu0xnGHb6fl4/5vAlzsd0iiyW8u+UiOfQl1paNxA26iQZTbxQ8fMLQsUnaOA8GcQ=="
}
}
}
}
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using Velopack.Sources;
namespace MarkdownViewr.Windows.UpdateProbe;
/// <summary>Maps only the smoke fixture's GitHub API and asset URLs to local bytes; never uses the network.</summary>
internal sealed class FixtureDownloader(string fixtureDirectory) : IFileDownloader
{
public List<string> Requests { get; } = [];
/// <summary>Persists request evidence before AppKit's apply operation exits the probe process.</summary>
public Action? RequestRecorded { get; set; }
/// <summary>Copies the exact release package for Velopack's normal hash verification.</summary>
public Task DownloadFile(string url, string targetFile, Action<int> progress, IDictionary<string, string>? headers = null, double timeout = 30, CancellationToken cancelToken = default)
{
cancelToken.ThrowIfCancellationRequested();
File.Copy(Resolve(url), targetFile, overwrite: true);
progress?.Invoke(100);
return Task.CompletedTask;
}
/// <summary>Reads a release channel feed without changing its bytes.</summary>
public Task<byte[]> DownloadBytes(string url, IDictionary<string, string>? headers = null, double timeout = 30)
=> File.ReadAllBytesAsync(Resolve(url));
/// <summary>Reads the GitHub release list used by the production GithubSource.</summary>
public Task<string> DownloadString(string url, IDictionary<string, string>? headers = null, double timeout = 30)
=> File.ReadAllTextAsync(Resolve(url));
/// <summary>Rejects unexpected hosts, paths, missing assets and traversal instead of falling back to HTTP.</summary>
private string Resolve(string url)
{
Requests.Add(url);
RequestRecorded?.Invoke();
var uri = new Uri(url);
string relative;
if(string.Equals(uri.Host, "api.github.com", StringComparison.Ordinal) && string.Equals(uri.AbsolutePath, "/repos/Example/InstalledProbe/releases", StringComparison.Ordinal))
{
relative = "releases.json";
}
else if(string.Equals(uri.Host, "github.com", StringComparison.Ordinal) && uri.AbsolutePath.StartsWith("/Example/InstalledProbe/releases/download/", StringComparison.Ordinal))
{
relative = uri.AbsolutePath["/Example/InstalledProbe/releases/download/".Length..];
}
else
{
throw new InvalidOperationException($"Unexpected fixture URL: {url}");
}
string root = Path.GetFullPath(fixtureDirectory) + Path.DirectorySeparatorChar;
string path = Path.GetFullPath(Path.Combine(root, Uri.UnescapeDataString(relative)));
if(!path.StartsWith(root, StringComparison.OrdinalIgnoreCase) || !File.Exists(path))
{
throw new FileNotFoundException("Fixture asset is missing or outside its root.", path);
}
return path;
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Test-only executable copied beside a published app by the installed smoke harness. -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="KofTwentyTwo.AppKit.Updates" />
</ItemGroup>
</Project>
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Reflection;
using KofTwentyTwo.AppKit.Updates;
using Velopack;
using Velopack.Sources;
namespace MarkdownViewr.Windows.UpdateProbe;
/// <summary>Replaces only HTTP transport in the pinned production service; never supplies a backend or locator.</summary>
internal static class OfflineService
{
/// <summary>Fails closed when private symbols change rather than falling back to network access.</summary>
public static (UpdateManager Manager, GithubSource Source) Attach(VelopackUpdateService service, FixtureDownloader downloader)
{
const BindingFlags Flags = BindingFlags.Instance | BindingFlags.NonPublic;
_ = service.IsSupported;
object backend = typeof(VelopackUpdateService).GetField("_backend", Flags)?.GetValue(service)
?? throw new InvalidOperationException("The pinned AppKit backend was not created.");
UpdateManager manager = backend.GetType().GetField("_manager", Flags)?.GetValue(backend) as UpdateManager
?? throw new InvalidOperationException("The pinned AppKit update manager was not found.");
GithubSource source = typeof(UpdateManager).GetProperty("Source", Flags)?.GetValue(manager) as GithubSource
?? throw new InvalidOperationException("The pinned AppKit GitHub source was not found.");
FieldInfo transport = source.GetType().BaseType?.GetField("<Downloader>k__BackingField", Flags)
?? throw new InvalidOperationException("The pinned Velopack downloader field was not found.");
transport.SetValue(source, downloader);
if(!ReferenceEquals(source.Downloader, downloader))
{
throw new InvalidOperationException("Offline transport was not installed; refusing to check for updates.");
}
return (manager, source);
}
}
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2026 James Maes
* SPDX-License-Identifier: MIT
*/
using System.Text.Json;
using KofTwentyTwo.AppKit.Updates;
using Velopack;
using Velopack.Locators;
using Velopack.Sources;
namespace MarkdownViewr.Windows.UpdateProbe;
/// <summary>Exercises AppKit's public installed update flow with offline GitHub release fixtures.</summary>
internal static class Program
{
/// <summary>Records the installed channel, update offer and requests; optionally downloads and applies it.</summary>
private static async Task<int> Main(string[] args)
{
VelopackStartup.Run();
if(args.Length != 3 || args[2] is not ("check" or "apply"))
{
throw new ArgumentException("Usage: UpdateProbe <fixture-directory> <result-json> <check|apply>", nameof(args));
}
var repository = new Uri("https://github.com/Example/InstalledProbe");
var publicService = new VelopackUpdateService(repository);
var downloader = new FixtureDownloader(args[0]);
(UpdateManager manager, GithubSource source) = OfflineService.Attach(publicService, downloader);
UpdateCheckResult update = await publicService.CheckAsync().ConfigureAwait(false);
if(update.Error is not null && (manager.IsInstalled || !string.Equals(update.Error, UpdateText.NotSupported, StringComparison.Ordinal)))
{
throw new InvalidOperationException(update.Error);
}
bool applyCalled = false;
string? applyError = null;
// Apply exits this process. Record every download request synchronously before that happens.
Action saveResult = () => File.WriteAllText(args[1], JsonSerializer.Serialize(new
{
UpdateDriver = "AppKit",
Installed = manager.IsInstalled,
AppKitSupported = publicService.IsSupported,
AppKitVersion = publicService.CurrentVersion,
Version = manager.CurrentVersion?.ToString(),
VelopackLocator.Current.Channel,
manager.IsPortable,
IncludePrereleases = source.Prerelease,
UpdateVersion = update.AvailableVersion,
AppKitCheckError = update.Error,
AppKitApplyCalled = applyCalled,
AppKitApplyError = applyError,
downloader.Requests
}));
downloader.RequestRecorded = saveResult;
saveResult();
if(string.Equals(args[2], "apply", StringComparison.Ordinal))
{
if(!update.IsUpdateAvailable)
{
throw new InvalidOperationException("No update was offered to apply.");
}
applyCalled = true;
saveResult();
applyError = await publicService.DownloadAndApplyAsync().ConfigureAwait(false);
saveResult();
if(applyError is not null)
{
throw new InvalidOperationException(applyError);
}
}
return 0;
}
}
@@ -0,0 +1,37 @@
{
"version": 2,
"dependencies": {
"net10.0-windows7.0": {
"KofTwentyTwo.AppKit.Updates": {
"type": "Direct",
"requested": "[0.1.0, )",
"resolved": "0.1.0",
"contentHash": "81vbwwOKoMtF7O423F8LYUTg8KjcyKQXqfqC6oWnRsQfDNQMgh+vaSKjmX6qmgvOAIY0dVsmu55TEpeRaJMGDw==",
"dependencies": {
"KofTwentyTwo.AppKit": "0.1.0",
"Velopack": "1.2.161"
}
},
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.296, )",
"resolved": "3.0.296",
"contentHash": "1Yp/6OK/Ev66hE2YLe9UvIgtJrKOX/xvoGDe0UbdtuG2ZxUoDoyONdEn+HWCDuy02Mu8SmglHEd/9SGPrM1qYg=="
},
"Velopack": {
"type": "Transitive",
"resolved": "1.2.161",
"contentHash": "OpHNacIumnkhaaX3ZlQ/TCR4YPOJvud2/PAOoWb6o+mIrLwIVA275I5wAgt4q/1O2VrJeDwR1qFQyqzG4lUArg=="
},
"KofTwentyTwo.AppKit": {
"type": "CentralTransitive",
"requested": "[0.1.0, )",
"resolved": "0.1.0",
"contentHash": "+ymM8DxPel+C527Cp7lrhhdu0xnGHb6fl4/5vAlzsd0iiyW8u+UiOfQl1paNxA26iQZTbxQ8fMLQsUnaOA8GcQ=="
}
},
"net10.0-windows7.0/win-arm64": {},
"net10.0-windows7.0/win-x64": {},
"net10.0-windows7.0/win-x86": {}
}
}