Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions .github/workflows/aot-trial.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: AOT trial

# Experiment, not a gate. NativeAOT needs the MSVC linker from the Desktop C++ workload, which the
# GitHub Windows runners have and a typical dev machine may not — so this is where the question
# "does OpenKey build and run AOT-compiled" actually gets answered.
#
# Reports size and startup against the current single-file build so the trade is a measurement
# rather than an argument. See docs/06 and docs/architecture/08-decisions.md.

on:
workflow_dispatch:
push:
branches: [feat/aot-and-distribution]

permissions:
contents: read

jobs:
aot:
runs-on: windows-latest

strategy:
fail-fast: false # arm64 failing must not hide an x64 result, or vice versa
matrix:
rid: [win-x64, win-arm64]

steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4

# Cross-compiling to arm64 needs the ARM64 C++ build tools, which are a separate component
# from the x64 ones. Installing them here rather than finding out during a release.
- name: Ensure ARM64 C++ tools
if: matrix.rid == 'win-arm64'
shell: pwsh
run: |
$vs = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vs_installer.exe"
$path = & "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -latest -property installationPath
Write-Host "Visual Studio at: $path"
& $vs modify --installPath "$path" --quiet --norestart --nocache `
--add Microsoft.VisualStudio.Component.VC.Tools.ARM64 | Out-Null
Write-Host "exit: $LASTEXITCODE"

- name: Publish AOT
run: >
dotnet publish src/OpenKey/OpenKey.csproj -c Release -r ${{ matrix.rid }}
-p:PublishAot=true
-p:PublishSingleFile=false
-p:PublishReadyToRun=false
-p:EnableCompressionInSingleFile=false
-o out-aot

- name: Publish the current single-file build for comparison
run: dotnet publish src/OpenKey/OpenKey.csproj -c Release -r ${{ matrix.rid }} -o out-singlefile

- name: Compare
shell: pwsh
run: |
$aot = Get-Item out-aot/OpenKey.exe
$single = Get-Item out-singlefile/OpenKey.exe
$aotMb = [math]::Round($aot.Length / 1MB, 1)
$singleMb = [math]::Round($single.Length / 1MB, 1)
Write-Host "single-file : $singleMb MB"
Write-Host "AOT : $aotMb MB"
Write-Host "delta : $([math]::Round($singleMb - $aotMb, 1)) MB smaller"

# A native build must not drag loose assemblies alongside it.
$dlls = (Get-ChildItem out-aot -Filter *.dll).Count
Write-Host "loose DLLs beside the AOT exe: $dlls"

- name: Smoke test the AOT binary
if: matrix.rid == 'win-x64' # an arm64 binary cannot execute on an x64 runner
shell: pwsh
run: |
# No key exists on a runner, so first-run setup runs and then aborts on EOF — for which
# the app correctly returns a non-zero exit code. That is expected here, so judge the
# rendered output rather than the exit status.
$out = "/quit`n" | ./out-aot/OpenKey.exe 2>&1 | Out-String
$global:LASTEXITCODE = 0
Write-Host $out

# Each assertion covers a distinct thing AOT could plausibly have broken.
if ($out -notmatch "OpenKey") { throw "no recognisable output — binary likely aborted on startup" }
if ($out -notmatch "Welcome to OpenKey") { throw "first-run copy missing — Spectre rendering is broken" }
if ($out -notmatch "OpenRouter") { throw "setup flow did not render" }
Write-Host "AOT binary starts, renders, and reaches first-run setup."

# always(): the artifact is the point of this workflow, and it is most wanted when a later
# step failed and someone needs to run the binary by hand.
- if: always()
uses: actions/upload-artifact@v4
with:
name: OpenKey-aot-${{ matrix.rid }}
path: out-aot/OpenKey.exe
55 changes: 50 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:

permissions:
contents: write
id-token: write # required by Azure Trusted Signing, when enabled

jobs:
publish:
Expand All @@ -33,16 +34,60 @@ jobs:
New-Item -ItemType Directory -Force dist | Out-Null
Copy-Item "out/${{ matrix.rid }}/OpenKey.exe" "dist/OpenKey-${{ matrix.rid }}.exe"

# Code signing removes the SmartScreen "unrecognized app" warning, which is the biggest
# friction point for a product handed over on a USB stick. Disabled until the secrets exist;
# enable by adding the four AZURE_* secrets and setting vars.SIGNING_ENABLED to 'true'.
# See docs/06-build-and-distribute.md.
- name: Sign
if: vars.SIGNING_ENABLED == 'true'
uses: azure/trusted-signing-action@v0
with:
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }}
endpoint: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
certificate-profile-name: ${{ secrets.AZURE_CERT_PROFILE }}
files-folder: dist
files-folder-filter: exe
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256

# Hashes are computed after signing, since signing changes the file.
- name: Checksum
shell: pwsh
run: |
$h = (Get-FileHash "dist/OpenKey-${{ matrix.rid }}.exe" -Algorithm SHA256).Hash.ToLower()
"$h OpenKey-${{ matrix.rid }}.exe" | Out-File -Encoding ascii "dist/${{ matrix.rid }}.sha256"
Write-Host "$h OpenKey-${{ matrix.rid }}.exe"

- uses: actions/upload-artifact@v4
with:
name: OpenKey-${{ matrix.rid }}
path: dist/OpenKey-${{ matrix.rid }}.exe
path: dist/*

release:
needs: publish
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')

steps:
- uses: actions/download-artifact@v4
with:
path: artifacts

# One checksums file per release, which is what the Scoop manifest's autoupdate reads.
- name: Collect checksums
run: |
mkdir -p dist
find artifacts -name '*.exe' -exec cp {} dist/ \;
cat artifacts/*/*.sha256 > "dist/OpenKey-${GITHUB_REF_NAME#v}-checksums.txt"
cat "dist/OpenKey-${GITHUB_REF_NAME#v}-checksums.txt"

# Release titles carry the version only — never a phase number. See docs/06.
- name: Attach to the release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v2
- uses: softprops/action-gh-release@v2
with:
name: ${{ github.ref_name }}
files: dist/OpenKey-${{ matrix.rid }}.exe
files: dist/*
generate_release_notes: true
7 changes: 2 additions & 5 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,8 @@ and a step change in scope rather than more polish.

Not scheduled; revisit when the trigger fires.

- **NativeAOT** — would cut the binary from ~42 MB to roughly 15–20 MB, remove the extract-to-temp
step on first run, and start faster. All three matter for the USB story. The old blocker
(Spectre reflection) is gone as of 0.55, and JSON source generation has landed, so the remaining
cost is measuring what the analyzers still report. `IsAotCompatible` is already on and the tree
is warning-clean.
- ~~**NativeAOT**~~ — **done.** 43 MB → 10.8 MB, ~0.16 s startup, nothing extracted to temp. See
[`docs/architecture/08-decisions.md`](docs/architecture/08-decisions.md).
- **`System.Net.ServerSentEvents`** — would replace the hand-rolled SSE reader. Preview-only today
(`11.0.0-preview.6`); adopt when it ships stable.
- **Bracketed paste** — a more robust multi-line paste than the current timing heuristic. Needs a
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
Notable changes to OpenKey. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- OpenKey is now a native binary: **11 MB instead of 43 MB**, starting in about a sixth of a
second, with nothing unpacked to a temporary folder the first time you run it. All three matter
most on a USB stick or someone else's machine.
- Installable with [Scoop](https://scoop.sh), which also avoids the "unrecognized app" prompt.

## [0.2.0] — 2026-08-03

### Added
Expand Down
12 changes: 11 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,17 @@ dotnet publish src\OpenKey\OpenKey.csproj -c Release -r win-x64 -o publish\

Every publish flag lives in `OpenKey.csproj`. Don't pass them on the command line, and don't
document a different command anywhere — the point is that CI and a developer machine produce the
same artifact. See [`docs/06-build-and-distribute.md`](docs/06-build-and-distribute.md).
same artifact.

OpenKey publishes as a NativeAOT binary, so that one command needs the MSVC linker:

```cmd
winget install Microsoft.VisualStudio.2022.BuildTools --override "--quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"
```

Without it you get *"Platform linker not found"*. `build`, `test` and `run` are unaffected, so you
only need this to cut a release build. See
[`docs/06-build-and-distribute.md`](docs/06-build-and-distribute.md).

## Before you open a PR

Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@ Patron ❯
## Get it

Download `OpenKey.exe` from [Releases](https://github.com/corecompiled/OpenKey/releases) and
double-click it. It runs from a USB stick.
double-click it. One 11 MB file, nothing installed, runs from a USB stick.

Or via [Scoop](https://scoop.sh), which also avoids the SmartScreen prompt:

```
scoop install https://raw.githubusercontent.com/corecompiled/OpenKey/main/packaging/scoop/openkey.json
```

You'll need a free [OpenRouter](https://openrouter.ai) key. OpenKey can fetch one through your
browser on first run, or you can paste one you already have. Either way it's encrypted for your
Expand Down Expand Up @@ -70,7 +76,7 @@ Requires the .NET SDK pinned in `global.json`. Windows only.

```cmd
dotnet run --project src\OpenKey\OpenKey.csproj # run
dotnet test # 65 tests
dotnet test # 87 tests
dotnet publish src\OpenKey\OpenKey.csproj -c Release -r win-x64 -o publish\
```

Expand Down
106 changes: 42 additions & 64 deletions docs/06-build-and-distribute.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ publish that didn't paste that exact line — including CI — silently produced
than the one that had been smoke-tested. Changing how the binary is built is a project-file edit,
reviewed like any other.

### Prerequisite: the C++ workload

OpenKey publishes as a NativeAOT binary, so `dotnet publish` needs the MSVC linker. Install once:

```cmd
winget install Microsoft.VisualStudio.2022.BuildTools --override "--quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"
```

For `win-arm64`, also add `Microsoft.VisualStudio.Component.VC.Tools.ARM64`.

Without it you get *"Platform linker not found"*. **`dotnet build`, `dotnet test` and `dotnet run`
are unaffected** — only publishing needs this.

For Windows on ARM, swap the RID:

```cmd
Expand All @@ -40,64 +53,41 @@ All set in `OpenKey.csproj`, not on the command line.

| Setting | Why |
|------|-----|
| `SelfContained` | Bundles the .NET runtime. The user doesn't need .NET installed — this is what makes it click-and-play. |
| `PublishSingleFile` | One file instead of a folder of DLLs. |
| `IncludeNativeLibrariesForSelfExtract` | Native libraries bundled and extracted to a temp dir at runtime. Required for a genuine single file. |
| `EnableCompressionInSingleFile` | Roughly 30% smaller, at the cost of a one-time decompression on first launch. |
| `PublishReadyToRun` | Pre-jits IL so startup feels instant. Larger file. |
| `IsAotCompatible` | Turns the trim/AOT analyzers on. Doesn't change the output; keeps the option open by failing the build on new reflection. |
| `PublishAot` | Compiles to a native binary. No JIT, no runtime to bundle, nothing extracted at startup. |
| `SelfContained` | Implied by AOT; stated for clarity. The user needs nothing installed. |
| `RuntimeIdentifiers` | `win-x64;win-arm64`. |
| `InvariantGlobalization=false` | LLM replies are full of non-ASCII text. Costs ICU in the bundle; a deliberate trade. |

## Why NOT trimming
`IsAotCompatible` is gone — it existed to surface trim/AOT warnings without committing to AOT, and
`PublishAot` implies the same analyzers.

```
# DO NOT add:
-p:PublishTrimmed=true
```

Trimming is not enabled yet. The trim analyzers *are* on (`IsAotCompatible` is set on all three
projects) and the tree builds warning-clean, so the historical objection — that Spectre.Console's
internal reflection would silently break the UI — no longer applies unexamined. Enabling
`PublishTrimmed` now needs measurement rather than argument.
## AOT, and why the old objection expired

## Why NOT AOT (yet)
This document used to say AOT was blocked by Spectre.Console's internal reflection. Measured from
the shipped assemblies, `IsTrimmable` metadata is **absent** in Spectre.Console 0.49.1 and
**present** in 0.55.2 — the library did the work. The other stated blocker, reflection-based
`System.Text.Json`, went away when everything persisted moved to source-generated contexts.

```
# Not enabled today:
-p:PublishAot=true
```
So the question became a measurement, and `.github/workflows/aot-trial.yml` answered it:

**The reason originally given here has expired.** This section used to say Spectre.Console's
reflection blocked AOT. Measured from the shipped assemblies: `IsTrimmable` metadata is **absent**
in Spectre.Console 0.49.1 and **present** in 0.55.2. The library did the work.
| | Single-file (previous) | NativeAOT (now) |
|---|---|---|
| Size | 43 MB | **10.8 MB** |
| Startup | ~1–2 s cold (decompress + extract) | **~0.16 s** |
| Extracts to temp on first run | yes | **no** |
| Loose DLLs beside the exe | n/a | 0 |

The other stated blocker, reflection-based `System.Text.Json`, is also gone — everything persisted
and every request body now goes through source-generated contexts.
All three differences land on the same thing: this is software people copy onto a USB stick and
run on someone else's machine.

Current status:
Verified on both architectures in CI, and the x64 binary was run locally against a live model —
streaming, markdown rendering, the tokenizer, DPAPI key load and config persistence all work
compiled. The trial workflow stays in the repo so the comparison can be re-run rather than
re-argued.

| Concern | State |
|---|---|
| Spectre.Console | Annotated trim/AOT-compatible since 0.55 |
| `System.Text.Json` | Source-generated contexts in place |
| Markdig | No analyzer warnings at our call sites |
| DPAPI via `ProtectedData` | AOT-safe |
| `Microsoft.Extensions.DependencyInjection` | Fine — composition is explicit, no assembly scanning |
The cost is the C++ workload prerequisite above. `build`, `test` and `run` are unaffected.

So what remains is measurement, not a known obstacle. The prize is real for a USB-distributed app:
roughly 42 MB → 15–20 MB, no extract-to-temp on first run, and faster startup. Tracked in
[`../BACKLOG.md`](../BACKLOG.md); rationale in
[`architecture/08-decisions.md`](architecture/08-decisions.md).

## Expected output

| Metric | Value |
|--------|-------|
| File size | ~30–50 MB (compressed) |
| First launch cold-start | ~1–2 s (decompress + R2R) |
| Subsequent launches | <500 ms |
| Working set RAM | ~80–120 MB |
Trimming is not separately enabled: AOT already implies it.

## Versioning

Expand Down Expand Up @@ -206,21 +196,9 @@ Implemented — see `.github/workflows/ci.yml` (build, test, and a publish check
and `.github/workflows/release.yml` (both architectures attached to a `v*` tag). The sketch that
used to live here has been replaced by the real thing.

For reference, the shape is:

```yaml
name: build
on: [push]
jobs:
publish:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '10.0.x' }
- run: dotnet publish src/OpenKey/OpenKey.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true -p:PublishReadyToRun=true -o publish
- uses: actions/upload-artifact@v4
with: { name: OpenKey-exe, path: publish/OpenKey.exe }
```
No sketch is reproduced here: a workflow copied into prose is a second source of truth that drifts
from the real one, which is the mistake the publish flags already made once. Read the files.

Tag-driven releases come in Phase 1.2 with the update checker.
A third workflow, `aot-trial.yml`, exists to re-measure AOT against the current single-file settings
on demand. It is an experiment rather than a gate, and it is where the numbers in this document
came from.
Loading
Loading