## What Brings AppKit into conformance with [KofTwentyTwo/standards](https://github.com/KofTwentyTwo/standards). Reviewable commit by commit: | Commit | Change | | --- | --- | | build | Quality gate from `configs/dotnet` (analyzers incl. Meziantou, warnings as errors), `packages.lock.json` per project, locked-mode restore | | style | Kingsrook layout and LF line endings, produced mechanically by `dotnet format` (formatting only) | | docs | SPDX license header on every C# file | | refactor | Real code changes: analyzer findings fixed (one type per file, regex timeouts, async void, …) and header comments on every type and member | | ci | Old workflows replaced by callers of the shared standards workflows (PR gates, security, CodeQL, .NET CI, NuGet release with SBOM + attestation + trusted publishing), Scorecard | | docs | README, CONTRIBUTING (GitHub Flow, DCO, signing), SECURITY, CODEOWNERS, templates, Dependabot, threat model, ADRs, RELEASING | Also includes `dev`'s CI fix, so it supersedes #1; the `dev` branch goes away under GitHub Flow. ## Why AppKit is the first Product repository to adopt the standards. Static conformance before: 16 FAIL; after: 0 FAIL, 0 WARN (the remaining UNKNOWN rows are GitHub settings and rulesets, applied separately). ## How tested Locally with `CI=true` (lock files enforced): locked restore x64 and ARM64; zero-warning x64 build; 115 unit tests with 100% line coverage of `KofTwentyTwo.AppKit` and `KofTwentyTwo.AppKit.Updates`; 12 FlaUI UI tests (WinUI + WPF samples); `dotnet format --verify-no-changes --severity warn` clean; pack produces 4 packages + symbols; actionlint, zizmor (pedantic), gitleaks clean. The UI tests caught a regression introduced while fixing MA0147 (the WPF splash stopped dismissing because no UI context existed yet); fixed by starting the countdown through the dispatcher in both packages. ## Notes - `ci.yml` and `release.yml` are pinned to KofTwentyTwo/standards#3's head commit; they are repinned after that PR merges. - This pull request's new check names become required checks once the repository's rulesets are updated. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: James Maes <james@kof22.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
152 lines
5.8 KiB
C#
152 lines
5.8 KiB
C#
/*
|
|
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
|
* SPDX-License-Identifier: MIT
|
|
*/
|
|
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Windows;
|
|
using System.Windows.Media;
|
|
using System.Windows.Media.Imaging;
|
|
using KofTwentyTwo.AppKit;
|
|
|
|
|
|
namespace BrandTool;
|
|
|
|
/// <summary>
|
|
/// brandtool --out <dir> [--start #RRGGBB] [--end #RRGGBB] [--glyph "<svg path data>"] [--stroke 56]
|
|
/// Writes app-icon.svg and app.ico (16..256 px PNG frames) into <dir>.
|
|
/// </summary>
|
|
internal static class Program
|
|
{
|
|
private const double Box = 512;
|
|
private const double TileInset = 48;
|
|
private const double TileRadius = 104;
|
|
private static readonly int[] s_iconSizes = [16, 20, 24, 32, 40, 48, 64, 128, 256];
|
|
|
|
|
|
|
|
/// <summary>Entry point: reads the options, then writes app-icon.svg and a multi-size app.ico to the output folder.</summary>
|
|
[STAThread]
|
|
private static int Main(string[] args)
|
|
{
|
|
Dictionary<string, string> options = Parse(args);
|
|
if(!options.TryGetValue("out", out string? outDir))
|
|
{
|
|
Console.Error.WriteLine("usage: brandtool --out <dir> [--start #RRGGBB] [--end #RRGGBB] [--glyph \"<path data>\"] [--stroke <width>]");
|
|
return 2;
|
|
}
|
|
|
|
var spec = new MarkSpec(
|
|
options.GetValueOrDefault("start", "#3B4CCA"),
|
|
options.GetValueOrDefault("end", "#2A2F8F"),
|
|
options.GetValueOrDefault("glyph", "M 200 152 L 312 256 L 200 360"),
|
|
double.Parse(options.GetValueOrDefault("stroke", "56"), CultureInfo.InvariantCulture));
|
|
|
|
Directory.CreateDirectory(outDir);
|
|
File.WriteAllText(Path.Combine(outDir, "app-icon.svg"), Svg(spec));
|
|
WriteIco(Path.Combine(outDir, "app.ico"), s_iconSizes.Select(size => Png(spec, size)).ToList(), s_iconSizes);
|
|
Console.WriteLine($"Wrote app-icon.svg and app.ico to {Path.GetFullPath(outDir)}");
|
|
return 0;
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Parses --name value pairs into a case-insensitive dictionary.</summary>
|
|
private static Dictionary<string, string> Parse(string[] args)
|
|
{
|
|
var options = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
for(int i = 0; i + 1 < args.Length; i += 2)
|
|
{
|
|
options[args[i].TrimStart('-')] = args[i + 1];
|
|
}
|
|
return options;
|
|
}
|
|
|
|
|
|
|
|
/// <summary>The mark as SVG text: the rounded gradient tile and the white stroked glyph.</summary>
|
|
private static string Svg(MarkSpec spec)
|
|
=> $"""
|
|
<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="{spec.Start}" />
|
|
<stop offset="1" stop-color="{spec.End}" />
|
|
</linearGradient>
|
|
</defs>
|
|
<rect x="{TileInset}" y="{TileInset}" width="{Box - 2 * TileInset}" height="{Box - 2 * TileInset}" rx="{TileRadius}" fill="url(#tile)" />
|
|
<path d="{spec.Glyph}" fill="none" stroke="#FFFFFF" stroke-width="{spec.Stroke.ToString(CultureInfo.InvariantCulture)}" stroke-linecap="round" stroke-linejoin="round" />
|
|
</svg>
|
|
|
|
""";
|
|
|
|
|
|
|
|
/// <summary>Renders the mark with WPF as a PNG of the given pixel size.</summary>
|
|
private static byte[] Png(MarkSpec spec, int size)
|
|
{
|
|
var visual = new DrawingVisual();
|
|
using(DrawingContext context = visual.RenderOpen())
|
|
{
|
|
double scale = size / Box;
|
|
context.PushTransform(new ScaleTransform(scale, scale));
|
|
var gradient = new LinearGradientBrush(ToColor(spec.Start), ToColor(spec.End), new Point(0, 0), new Point(1, 1));
|
|
context.DrawRoundedRectangle(gradient, null, new Rect(TileInset, TileInset, Box - 2 * TileInset, Box - 2 * TileInset), TileRadius, TileRadius);
|
|
var pen = new Pen(Brushes.White, spec.Stroke) { StartLineCap = PenLineCap.Round, EndLineCap = PenLineCap.Round, LineJoin = PenLineJoin.Round };
|
|
context.DrawGeometry(null, pen, Geometry.Parse(spec.Glyph));
|
|
}
|
|
|
|
var bitmap = new RenderTargetBitmap(size, size, 96, 96, PixelFormats.Pbgra32);
|
|
bitmap.Render(visual);
|
|
var encoder = new PngBitmapEncoder();
|
|
encoder.Frames.Add(BitmapFrame.Create(bitmap));
|
|
using var stream = new MemoryStream();
|
|
encoder.Save(stream);
|
|
return stream.ToArray();
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Writes an .ico whose frames are embedded PNGs (supported since Windows Vista).</summary>
|
|
private static void WriteIco(string path, List<byte[]> pngs, int[] sizes)
|
|
{
|
|
using var writer = new BinaryWriter(File.Create(path));
|
|
writer.Write((ushort)0); // reserved
|
|
writer.Write((ushort)1); // type: icon
|
|
writer.Write((ushort)pngs.Count);
|
|
int offset = 6 + 16 * pngs.Count;
|
|
for(int i = 0; i < pngs.Count; i++)
|
|
{
|
|
writer.Write((byte)(sizes[i] >= 256 ? 0 : sizes[i])); // 0 means 256
|
|
writer.Write((byte)(sizes[i] >= 256 ? 0 : sizes[i]));
|
|
writer.Write((byte)0); // palette
|
|
writer.Write((byte)0); // reserved
|
|
writer.Write((ushort)1); // planes
|
|
writer.Write((ushort)32); // bits per pixel
|
|
writer.Write(pngs[i].Length);
|
|
writer.Write(offset);
|
|
offset += pngs[i].Length;
|
|
}
|
|
foreach(byte[] png in pngs)
|
|
{
|
|
writer.Write(png);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// <summary>Parses a #RRGGBB or #AARRGGBB string into a WPF color.</summary>
|
|
private static Color ToColor(string hex)
|
|
{
|
|
var color = ArgbColor.Parse(hex);
|
|
return Color.FromArgb(color.A, color.R, color.G, color.B);
|
|
}
|
|
|
|
|
|
|
|
/// <summary>The mark definition: gradient colors, the glyph's SVG path data, and its stroke width.</summary>
|
|
private sealed record MarkSpec(string Start, string End, string Glyph, double Stroke);
|
|
}
|