From 2332af54b6c81b1af389eeb7912191031e39766f Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:46:40 +0800 Subject: [PATCH 1/4] ci: trial NativeAOT on a runner that has the C++ toolchain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AOT compiled cleanly here — zero IL warnings from the trim/AOT analyzers, which retires the last open question about Markdig — but the native link step needs the MSVC linker from the Desktop C++ workload, and this machine does not have it registered (VsDevCmd sets no VCToolsInstallDir, and `where link.exe` finds Git's unrelated link.exe). Installing a multi-gigabyte workload to answer a sizing question is the wrong trade when the GitHub Windows runners already have it. CI is also where releases are actually built, so proving it there is worth more than proving it locally. Reports AOT size against the current single-file build and smoke-tests that the native binary starts and renders, since AOT failures typically surface as an immediate abort rather than a build error. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ --- .github/workflows/aot-trial.yml | 67 +++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/workflows/aot-trial.yml diff --git a/.github/workflows/aot-trial.yml b/.github/workflows/aot-trial.yml new file mode 100644 index 0000000..5f3919e --- /dev/null +++ b/.github/workflows/aot-trial.yml @@ -0,0 +1,67 @@ +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 + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + + - name: Publish AOT + run: > + dotnet publish src/OpenKey/OpenKey.csproj -c Release -r win-x64 + -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 win-x64 -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 + shell: pwsh + run: | + # No key is configured on a runner, so first-run setup is expected. What matters is that + # the native binary starts, renders, and exits cleanly instead of crashing on startup — + # AOT failures usually surface as an immediate abort. + $out = "/quit`n" | ./out-aot/OpenKey.exe 2>&1 | Out-String + Write-Host $out + if ($out -notmatch "OpenKey") { throw "AOT binary produced no recognisable output" } + Write-Host "AOT binary starts and renders." + + - uses: actions/upload-artifact@v4 + with: + name: OpenKey-aot-win-x64 + path: out-aot/OpenKey.exe From 3c4535bc51567a11b0448149b2dc7bc0825ae419 Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:51:32 +0800 Subject: [PATCH 2/4] ci: judge the AOT smoke test on output, not exit code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aborting first-run with no key is a correct non-zero exit, which failed the step even though the binary had rendered fine. Now asserts on what was drawn, and covers the first-run copy as well as startup so a Spectre rendering break under AOT cannot pass silently. Artifact uploads unconditionally — it is most wanted when a step failed and someone needs to run the binary by hand. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ --- .github/workflows/aot-trial.yml | 20 ++++++++---- .github/workflows/release.yml | 55 ++++++++++++++++++++++++++++++--- packaging/scoop/openkey.json | 48 ++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 packaging/scoop/openkey.json diff --git a/.github/workflows/aot-trial.yml b/.github/workflows/aot-trial.yml index 5f3919e..5a06e18 100644 --- a/.github/workflows/aot-trial.yml +++ b/.github/workflows/aot-trial.yml @@ -53,15 +53,23 @@ jobs: - name: Smoke test the AOT binary shell: pwsh run: | - # No key is configured on a runner, so first-run setup is expected. What matters is that - # the native binary starts, renders, and exits cleanly instead of crashing on startup — - # AOT failures usually surface as an immediate abort. + # 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 - if ($out -notmatch "OpenKey") { throw "AOT binary produced no recognisable output" } - Write-Host "AOT binary starts and renders." - - uses: actions/upload-artifact@v4 + # 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-win-x64 path: out-aot/OpenKey.exe diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c560893..d9742eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,7 @@ on: permissions: contents: write + id-token: write # required by Azure Trusted Signing, when enabled jobs: publish: @@ -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 diff --git a/packaging/scoop/openkey.json b/packaging/scoop/openkey.json new file mode 100644 index 0000000..5220f99 --- /dev/null +++ b/packaging/scoop/openkey.json @@ -0,0 +1,48 @@ +{ + "version": "0.2.0", + "description": "Chat with capable AI models for free, from a single Windows exe. No install, no subscription.", + "homepage": "https://github.com/corecompiled/OpenKey", + "license": "MIT", + "architecture": { + "64bit": { + "url": "https://github.com/corecompiled/OpenKey/releases/download/v0.2.0/OpenKey-win-x64.exe#/OpenKey.exe", + "hash": "REPLACE_WITH_SHA256_WIN_X64" + }, + "arm64": { + "url": "https://github.com/corecompiled/OpenKey/releases/download/v0.2.0/OpenKey-win-arm64.exe#/OpenKey.exe", + "hash": "REPLACE_WITH_SHA256_WIN_ARM64" + } + }, + "bin": "OpenKey.exe", + "shortcuts": [ + [ + "OpenKey.exe", + "OpenKey" + ] + ], + "notes": [ + "Run 'openkey' from any terminal, or use the Start menu shortcut.", + "", + "On first run OpenKey asks for a free OpenRouter key and can fetch one through your browser.", + "Your key is encrypted for your Windows account; settings live in %APPDATA%\\OpenKey.", + "", + "'scoop uninstall openkey' removes the app but leaves %APPDATA%\\OpenKey alone.", + "Run '/reset' inside OpenKey first if you want your key and conversation erased too." + ], + "checkver": { + "github": "https://github.com/corecompiled/OpenKey" + }, + "autoupdate": { + "architecture": { + "64bit": { + "url": "https://github.com/corecompiled/OpenKey/releases/download/v$version/OpenKey-win-x64.exe#/OpenKey.exe" + }, + "arm64": { + "url": "https://github.com/corecompiled/OpenKey/releases/download/v$version/OpenKey-win-arm64.exe#/OpenKey.exe" + } + }, + "hash": { + "url": "$baseurl/OpenKey-$version-checksums.txt" + } + } +} From 466a26c80d6eb00f13630ba9ad745966bb826c71 Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:56:43 +0800 Subject: [PATCH 3/4] ci: prove AOT for arm64 too before adopting it x64 AOT is 10.8 MB against 43 MB and starts in 0.16s, verified against a real key. Before making that the shipping configuration, arm64 has to be proven as well: cross-compiling needs the ARM64 C++ build tools, a separate component from the x64 ones, and a release tag is the wrong place to discover it is missing. fail-fast off so one architecture failing still reports the other. The smoke test stays x64-only, since an arm64 binary cannot execute on an x64 runner. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ --- .github/workflows/aot-trial.yml | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/aot-trial.yml b/.github/workflows/aot-trial.yml index 5a06e18..017a010 100644 --- a/.github/workflows/aot-trial.yml +++ b/.github/workflows/aot-trial.yml @@ -19,13 +19,31 @@ 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 win-x64 + dotnet publish src/OpenKey/OpenKey.csproj -c Release -r ${{ matrix.rid }} -p:PublishAot=true -p:PublishSingleFile=false -p:PublishReadyToRun=false @@ -33,7 +51,7 @@ jobs: -o out-aot - name: Publish the current single-file build for comparison - run: dotnet publish src/OpenKey/OpenKey.csproj -c Release -r win-x64 -o out-singlefile + run: dotnet publish src/OpenKey/OpenKey.csproj -c Release -r ${{ matrix.rid }} -o out-singlefile - name: Compare shell: pwsh @@ -51,6 +69,7 @@ jobs: 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 @@ -71,5 +90,5 @@ jobs: - if: always() uses: actions/upload-artifact@v4 with: - name: OpenKey-aot-win-x64 + name: OpenKey-aot-${{ matrix.rid }} path: out-aot/OpenKey.exe From 2bd1ce43818922db9149a0bb8d8525a9279bc30f Mon Sep 17 00:00:00 2001 From: corecompiled <285886213+corecompiled@users.noreply.github.com> Date: Mon, 3 Aug 2026 06:08:08 +0800 Subject: [PATCH 4/4] build: ship NativeAOT, add Scoop packaging and signing hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AOT was recorded as "watching" with the note that its blocker had expired and only measurement remained. Measured, via a trial workflow that stays in the repo so this can be re-checked rather than re-argued: size 43 MB -> 10.8 MB startup ~1-2 s -> ~0.16 s extracts to temp on first run: yes -> no loose DLLs beside the exe: 0 Every one of those lands on the same thing: software copied onto a USB stick and run on a machine with nothing installed. Proven on both architectures in CI — arm64 needs a separate C++ component, which the trial installs rather than leaving a release tag to discover — and the x64 binary was run locally against a live model. Streaming, markdown, the tokenizer, DPAPI and config persistence all work compiled. Markdig, the one library whose AOT behaviour was unverified, is fine. The cost is real and is documented rather than buried: `dotnet publish` now needs the MSVC linker from the Desktop C++ workload. `build`, `test` and `run` do not, so day-to-day work is unchanged, but producing a release build requires a one-time install. Distribution: - Scoop manifest with checkver and autoupdate, hashes pinned to v0.2.0. Installing through Scoop sidesteps SmartScreen entirely, which is much of the point. - Release workflow emits a per-release checksums asset, which is what Scoop's autoupdate reads. - Azure Trusted Signing wired into the release workflow behind a SIGNING_ENABLED flag, skipped until the secrets exist. Signing runs before checksums, since it changes the file. - packaging/README.md documents the free Microsoft reputation submission and the signing route, since both need a human at a web form. Also removed the CI sketch that had been copied into docs/06 — a workflow reproduced in prose is a second source of truth that drifts from the real one, which is exactly the mistake the publish flags already made once. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WGcarVYpt1mfjk6iFkcDpZ --- BACKLOG.md | 7 +- CHANGELOG.md | 9 +++ CONTRIBUTING.md | 12 +++- README.md | 10 ++- docs/06-build-and-distribute.md | 106 ++++++++++++------------------ docs/architecture/08-decisions.md | 35 +++++++--- packaging/README.md | 88 +++++++++++++++++++++++++ packaging/scoop/openkey.json | 4 +- src/OpenKey/OpenKey.csproj | 20 +++--- 9 files changed, 199 insertions(+), 92 deletions(-) create mode 100644 packaging/README.md diff --git a/BACKLOG.md b/BACKLOG.md index e39aa04..24fa30f 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index e04ddea..3a4ba3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f1d410..6adad66 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index 3358fcf..7235bb8 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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\ ``` diff --git a/docs/06-build-and-distribute.md b/docs/06-build-and-distribute.md index ea65e41..12e0811 100644 --- a/docs/06-build-and-distribute.md +++ b/docs/06-build-and-distribute.md @@ -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 @@ -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 @@ -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. diff --git a/docs/architecture/08-decisions.md b/docs/architecture/08-decisions.md index 152a973..73ae6ab 100644 --- a/docs/architecture/08-decisions.md +++ b/docs/architecture/08-decisions.md @@ -93,20 +93,35 @@ escapes corruption handling. It meant session restore had never worked. --- -## NativeAOT: not now, but the old reason is dead +## NativeAOT: adopted -**Status:** watching +**Status:** decided, measured -`docs/06-build-and-distribute.md` rejected AOT because Spectre.Console used reflection. Measured -from the shipped assemblies: `IsTrimmable` is **absent** in Spectre 0.49.1 and **present** in -0.55.2. That reason expired. +This document previously recorded AOT as "watching", because the stated blocker — Spectre.Console's +internal reflection — had expired without anyone checking. Measured from the shipped assemblies, +`IsTrimmable` is absent in Spectre 0.49.1 and present in 0.55.2. -The other stated blocker, `System.Text.Json` reflection, is gone as of the source-generation work. -`IsAotCompatible` is on across all three projects and the tree builds warning-clean, which also -answers the open question about Markdig. +Measured rather than argued, via `.github/workflows/aot-trial.yml`: -The prize is real for a USB-distributed app: roughly 42 MB → 15–20 MB, no extract-to-temp on first -run, faster startup. What remains is measurement, not a known obstacle. +| | Single-file | NativeAOT | +|---|---|---| +| Size | 43 MB | **10.8 MB** | +| Startup | ~1–2 s cold | **~0.16 s** | +| Extract to temp on first run | yes | **no** | + +Every one of those differences matters specifically because this is software copied onto a USB +stick and run on a machine that has nothing installed. + +Verified on both architectures in CI, and the x64 binary run locally against a live model: +streaming, markdown, tokenizer, DPAPI and config persistence all work compiled. Markdig, the one +library whose AOT behaviour was unverified, is fine. + +**The cost** is that `dotnet publish` now needs the MSVC linker from the Desktop C++ workload. +`build`, `test` and `run` do not, so day-to-day work is unchanged. That was judged an acceptable +price for a 4× smaller, instantly-starting binary — but it is a real cost, and it falls on anyone +who wants to produce a release build. + +The trial workflow stays in the repo so this can be re-measured rather than re-litigated. --- diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..745ad5b --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,88 @@ +# Packaging and distribution + +Everything about getting OpenKey onto someone else's machine. + +## SmartScreen — the biggest barrier + +An unsigned binary triggers *"Windows protected your PC — unrecognized app"*, and the user has to +click **More info → Run anyway**. For software whose whole distribution story is "copy it to a USB +stick and hand it to a friend", that warning costs more adoption than any missing feature. + +Two ways to reduce it, in increasing order of cost. + +### 1. Reputation submission (free, one form, do this first) + +Submit each released binary to Microsoft for review: + + + +- Choose **Software developer** as the submission type. +- Upload `OpenKey-win-x64.exe` (repeat for `win-arm64`). +- State that it is a false positive: an unsigned open-source .NET console app, source at + . + +This does not remove the warning immediately. It clears active detections and helps reputation +accumulate as downloads do. It costs nothing but time, so there is no reason not to. + +### 2. Code signing (removes it properly) + +**Azure Trusted Signing** is the cheap route — roughly $10/month for the individual/small-business +tier, against a few hundred a year for a traditional EV certificate. It requires identity +verification, which takes a few days. + +The release workflow is already wired for it. Enable by adding these repository secrets: + +| Secret | What | +|---|---| +| `AZURE_TENANT_ID` | Directory tenant | +| `AZURE_CLIENT_ID` | Service principal | +| `AZURE_CLIENT_SECRET` | Service principal secret | +| `AZURE_SIGNING_ENDPOINT` | e.g. `https://eus.codesigning.azure.net` | +| `AZURE_SIGNING_ACCOUNT` | Trusted Signing account name | +| `AZURE_CERT_PROFILE` | Certificate profile name | + +Then set the repository variable `SIGNING_ENABLED` to `true`. The signing step runs before +checksums are computed, since signing changes the file. + +Until those exist the step is skipped and releases ship unsigned, exactly as now. + +## Scoop + +`packaging/scoop/openkey.json` installs the released binary: + +``` +scoop install https://raw.githubusercontent.com/corecompiled/OpenKey/main/packaging/scoop/openkey.json +``` + +Installing through Scoop sidesteps SmartScreen entirely, which is a large part of why it is worth +having. + +To offer the shorter `scoop install openkey`, the manifest needs to live in a bucket — either +submitted to [`ScoopInstaller/Extras`](https://github.com/ScoopInstaller/Extras) or published as +`corecompiled/scoop-bucket`. Extras generally wants a package with some existing usage, so a +personal bucket is the sensible first step. + +`checkver` and `autoupdate` are configured, so a new tag is picked up automatically. Autoupdate +reads `OpenKey--checksums.txt`, which the release workflow publishes as a release asset. + +### After each release + +Update `version` and both hashes. From a release directory: + +```pwsh +Get-FileHash OpenKey-win-x64.exe -Algorithm SHA256 +``` + +Or take them straight from the `checksums.txt` asset. + +## winget + +Worth doing once there is download history — `microsoft/winget-pkgs` involves a manifest PR and a +review cycle, which is more friction than Scoop for the same benefit. Revisit after a release or +two. + +## What is deliberately not done + +**No auto-update installer, in any phase.** Check-and-notify only. A tool that silently replaces +its own binary is a tool people are right to distrust, and it fights the "one file you can copy +anywhere" model. This is a standing project rule. diff --git a/packaging/scoop/openkey.json b/packaging/scoop/openkey.json index 5220f99..05d8629 100644 --- a/packaging/scoop/openkey.json +++ b/packaging/scoop/openkey.json @@ -6,11 +6,11 @@ "architecture": { "64bit": { "url": "https://github.com/corecompiled/OpenKey/releases/download/v0.2.0/OpenKey-win-x64.exe#/OpenKey.exe", - "hash": "REPLACE_WITH_SHA256_WIN_X64" + "hash": "1cf95b6bdcd881962172ac21d45232264c086ba2706e7f9e66bef002232f8341" }, "arm64": { "url": "https://github.com/corecompiled/OpenKey/releases/download/v0.2.0/OpenKey-win-arm64.exe#/OpenKey.exe", - "hash": "REPLACE_WITH_SHA256_WIN_ARM64" + "hash": "1be2fdba94f1d8311ae62fa3394883f7ed1ce02733c18e054326453f8134b717" } }, "bin": "OpenKey.exe", diff --git a/src/OpenKey/OpenKey.csproj b/src/OpenKey/OpenKey.csproj index a6c45c2..b155b1a 100644 --- a/src/OpenKey/OpenKey.csproj +++ b/src/OpenKey/OpenKey.csproj @@ -9,15 +9,19 @@ - true - true - true - true - true + must produce the same artifact that gets smoke-tested and shipped. See docs/06. + + NativeAOT: measured at 10.8 MB against 43 MB for the previous single-file build, starting + in ~0.16s with nothing extracted to a temp directory on first run. All three matter for + something handed over on a USB stick. Verified on both architectures in CI and against a + live model locally. - - true + `dotnet publish` therefore needs the MSVC linker from the "Desktop development with C++" + workload; see docs/06 for the exact install command. build, test and run are unaffected, + so day-to-day work does not change. --> + true + true + false