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:
92 files changed
+8027
No files matched your search
+211
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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: ["*"]
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 });
|
||||
@@ -0,0 +1,54 @@
|
||||
# OpenSSF Scorecard (K22-CI-24). Scorecard's publishing API only accepts results
|
||||
# from a workflow shaped exactly like this one: a single job, read-only top-level
|
||||
# permissions, no top-level env or defaults, and only the allowed steps.
|
||||
name: scorecard
|
||||
|
||||
on:
|
||||
branch_protection_rule:
|
||||
schedule:
|
||||
- cron: "17 7 * * 1"
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: scorecard-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: analysis
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read # Scorecard reads workflow run metadata
|
||||
security-events: write # upload SARIF to code scanning
|
||||
id-token: write # publish results to the OpenSSF API and badge
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Scorecard
|
||||
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
|
||||
with:
|
||||
results_file: scorecard.sarif
|
||||
results_format: sarif
|
||||
publish_results: true
|
||||
|
||||
- name: Keep the results
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: scorecard-sarif
|
||||
path: scorecard.sarif
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload SARIF to code scanning
|
||||
uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
with:
|
||||
sarif_file: scorecard.sarif
|
||||
category: scorecard
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
@@ -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
|
||||
@@ -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]
|
||||
@@ -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.
|
||||
@@ -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
@@ -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).
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/KofTwentyTwo/MarkdownViewr.Windows)
|
||||
[](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
@@ -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).
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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.'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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*'
|
||||
}
|
||||
}
|
||||
@@ -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*'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)).
|
||||
@@ -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/).
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.100",
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Generated
+1074
File diff suppressed because it is too large.
Load diff
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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'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'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": {}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user