This file must be kept up to date. When a rule here stops matching reality, or a new rule emerges from work in this repository, update this file as part of that change rather than leaving it to drift.
This folder is System Hotkeys (manifest.json name), a Macro Deck 3 out-of-process plugin
(Windows and macOS) that installs a global keyboard hook (WH_KEYBOARD_LL on Windows, a Quartz event tap
on macOS) and publishes a hotkey-pressed
event whenever a modifier+key combination is pressed anywhere on the machine, so any Automation or
widget flow can bind to a system-wide hotkey. The hook is passive by default; a trigger can opt in to
also swallowing its combo's trailing key, and can scope itself to one device, profile and/or folder.
README.md is the human-facing guide: how to build, how to run against a real host, how to pack. This file is the rule set for writing the plugin. Read it before changing code. The template package itself is covered by packaging/README.md.
src/SystemHotkeys/
Program.cs builder chain - a few lines and a RunAsync
manifest.json identity, icon, win-x64 and osx-arm64 entrypoints
macrodeck-build.json one publish target per entrypoint (framework-dependent)
PluginIntegration.cs IPluginIntegration + IEventProvider + IDynamicEventOptionsProvider: the
hotkey-pressed event, its combo/suppress/scope parameters, and reading
bound triggers back through IEventPublisher.GetBindings
HookAccessNotifications.cs shows or clears a notification for each OS permission the hook lacks
Hotkeys/ the keyboard hook itself
KeyboardHook.cs the platform-neutral half: held keys, combo detection, which key events a
suppressed combo swallows, the access level, and the per-OS factory
Windows/ WindowsKeyboardHook (WH_KEYBOARD_LL plus its message loop) and its P/Invoke
MacOS/ MacKeyboardHook (Quartz event tap on a CFRunLoop thread) and its P/Invoke
HotkeyCombo.cs held keys into the { modifiers, key } shape the combo editor stores
BoundHotkeyCombos.cs turns GetBindings() into the set of combos currently opted into
suppression
HotkeyPressedPayloads.cs builds the payload(s) a single press publishes
Localization/Strings.resx default-culture strings; Strings.<culture>.resx per language
Assets/icon.svg the icon the manifest declares
Properties/launchSettings.json the single real-host debug profile
tests/SystemHotkeys.Tests/
PluginIntegrationTests.cs the event's shape through the harness; LocalizationTests guards the
catalog wiring
Hotkeys/ unit tests for the combo/key-name/binding logic above - the actual OS
hook is not something a test harness can drive
Design knowledge that is not obvious from the code alone:
- The hook only ever observes unless a bound trigger opted into suppression.
KeyboardHook.OnKeyDownandOnKeyUpreturntrueonly for a key-down/repeat/key-up that matches a combo currently inBoundHotkeyCombos; every backend passes everything else on untouched. Only the combo's trailing key is swallowed - never its modifiers - so a bind that uses a bare Ctrl or Shift never also eats whatever else that modifier does elsewhere.BoundHotkeyCombos.FromBindingsis the single place that decides "currently bound and suppressed", rebuilt from scratch on everyEvents.BindingsChanged. BoundHotkeyCombosis recomputed on two different signals, not one.Events.BindingsChangedcovers a trigger's combo or suppress toggle being added, edited or removed. A client's position (which folder/profile it currently has open) never raises that event, soPluginIntegrationalso watchesDeck.ClientChanged- otherwise a Device/Profile/Folder-scoped suppression would go stale the moment someone changes folder.- A press fans out to one occurrence per connected client only when scoping could actually change
which trigger reacts.
_anyBindingIsScoped(fromBoundHotkeyCombos.AnyBindingIsScoped) gates this: with nothing scoped, one publish already reaches every trigger, so fanning out would only fire each of them once per client for no reason. With at least one scoped binding,HotkeyPressedPayloads.ForComboemits one payload per connected client, carrying that client's own device/profile/folder, so the host's trigger matching can tell apart a shared combo bound once per folder. DevicesOptionsSourceId = "macrodeck.devices"is host-owned, not part of the public SDK. It is the same id the built-in Client Connected/Disconnected triggers use for their own device picker, backed by Macro Deck's device registry - the one place a device has the name its owner actually gave it, rather than an id nobody recognises. Undocumented and unverified against a real host from here, so it could stop resolving without notice on a future host; check the picker still offers real device names after any host update.- Key names come from the SDK, never from a table of our own. A backend translates its native code with
MacroDeck.Sdk.Input.NativeKeys(FromWindows(vk, scanCode, isExtended),FromMacOs) and handsKeyboardHookaKeyCode;KeyNames.ToNameandToModifierNamesproduce exactly what the combo editor stores (Macro Deck ADR 0104: letters by layout label, digits and punctuation by position,Minus,IntlBackslash,NumpadEnter). A key the SDK returnsKeyCode.Nonefor is ignored. - Right-hand modifiers publish the generic name. The editor only stores
RightCtrland friends for the host's own keyboard actions, never for event triggers, soHotkeyCombo.Createfolds every right-hand modifier intoCtrl/Shift/Alt/Metabefore naming it. - Combo matching follows the host's trigger matching.
BoundHotkeyCombos.NormalizedKeyresolves key and modifier names throughKeyNames.TryParse/TryParseModifier, becauseGetBindings()returns whatever spelling the editor stored (-,Esc,Cmd, ...). Unknown names compare case-insensitively as text. - No config flow. Every setting lives on the
hotkey-pressedtrigger itself (Device/Profile/Folder scope, the suppress toggle), so the integration starts enabled immediately rather than waiting on setup. - A new platform is an event source plus an SDK lookup. A backend file is marked
[SupportedOSPlatform]and onlyKeyboardHook.Createpicks one, behind anOperatingSystem.Is*guard. An OS with no backend gets a hook that only logs a warning; it must never throw, because the integration is constructed duringBuild(). - A failed hook never takes the plugin down. The conformance suite runs each declared platform on a CI
runner with no keyboard access at all (no macOS permission granted), and it still has to pass. A
backend logs and reports its
KeyboardHookAccess; it never throws out ofStart. - macOS permissions decide what the tap can do, and can change under a running tap.
MacKeyboardHooktries an active tap first (needs Accessibility, can swallow), falls back to listen-only (needs Input Monitoring, cannot swallow), and with neither reportsDeniedand retries every 5 seconds. Every half-second slice it probes whether an active tap can still be created and reinstalls the tap when that answer changes: a grant upgrades without a restart, and a revocation must be noticed because an active tap whose Accessibility was taken away freezes all keyboard and mouse input. The probe is a throwaway active tap listening only tokCGEventNull, becauseAXIsProcessTrustedkept reporting false in a running process after a grant. - TCC attributes macOS permissions to the responsible process. That is Macro Deck for an installed
plugin, not the plugin's
dotnet, but the terminal app (VS Code, Terminal) for one started withmake run/make watch, so test a permission change by toggling that app.PluginIntegrationturns the access level into a notification:Deniedalways,ObserveOnlyonly while some binding asks for suppression.UserNotificationRequesttakes plain strings, soHookAccessNotificationsresolves theStringskeys itself through its ownLocalizationResolver. - macOS modifiers come from the event flags, not from key presses. Every key event resyncs the held
modifiers from the device-dependent
CGEventFlagsbits (the only part that tells left from right), so a modifier released while the tap was blind (Secure Event Input) cannot stick. A non-repeat key-down of a key still marked held clears that stale state first, for the same reason. Command isMeta, Option isAlt. NativeKeys.FromMacOsreads the active input source from the event tap thread. Apple documents the Text Input Sources API for the main thread and the SDK only tested it from a thread-pool thread, so check letters on a non-US layout (GermanZ/Y) after any change to the tap or an SDK update.- Linux is not declared yet. Wayland offers no global key observation, so a Linux backend would read
evdev (
/dev/input, user in theinputgroup). Swallowing a key there means grabbing the whole keyboard and re-emitting every other key through uinput, or the XDG GlobalShortcuts portal. - Framework-dependent on every platform. Each entrypoint names
runtimes/<rid>/SystemHotkeys.dllwith"runtime": { "kind": "FrameworkDependent", "dotnetVersion": "10.0" }, andmacrodeck-build.jsonpublishes with--self-contained false -p:UseAppHost=falseto match - Macro Deck ships the .NET 10 runtime with the host, so the artifact carries only this plugin's own assemblies. The DLL is plain IL, so one machine builds every platform. Keep the manifest andmacrodeck-build.jsonon the same pairing, with one target per entrypoint; mixing them fails validation.
Authoritative upstream documentation, in the
Macro Deck 3 repository:
sdk-reference.md (every contract type), plugin-hosting.md (builder, registration modes, manifest,
artifact, environment variables), capability-parity.md (what behaves differently out of process),
analyzers.md, conformance.md, testing-plugins.md, cli.md. When a question is about SDK behaviour
rather than this template's own code, look there rather than guessing.
This plugin is published through the Macro Deck Store, and the Creator Portal refuses a build that breaks its policy. The policy changes over time, so never rely on a copy, on memory or on an earlier fetch in the same session. Fetch every document below again before you start a change and again before you call it done. If one cannot be fetched, say so and stop rather than assuming it still says what it said last time.
| What | Where |
|---|---|
| Creator Guidelines (Markdown) | https://api.macro-deck.app/api/v1/public/creator-guidelines |
| Blocked packages (JSON) | https://api.macro-deck.app/api/v1/public/dependency-policy/blocked-packages |
| Minimum SDK version and allowed Macro Deck packages (JSON) | https://api.macro-deck.app/api/v1/public/dependency-policy/sdk |
A change is not done until all six hold:
- The plugin follows the Creator Guidelines. Read the whole document, not just the part that seems relevant, and check the change against every rule in it. Where a rule here and the guidelines disagree, the guidelines win. Point out the conflict so this file can be corrected.
- No blocked package is used. Check every entry in
blockedPackagesagainst the full dependency graph (dotnet list package --include-transitive), not only the packages referenced directly. An entry matches bypackagePattern. AversionPatternofnullblocks every version. Otherwise only the matching versions are blocked.reasonsays why. Replace the package or restructure the code that needs it. Never work around a block by vendoring, renaming or loading the package some other way. - The Macro Deck SDK is at least
minimumSdkVersion.MacroDeckSdkVersioninDirectory.Packages.propsis pinned rather than floated (see the comment there for why), so this does not hold automatically - check the pinned version against the policy before bumping it, and never build a release against an SDK older than the minimum or againstlocal-feed/. - Only allowed Macro Deck packages are used. Every package in the graph whose id starts with
MacroDeck.must match an entry inallowedMacroDeckPackages. Anything else under that prefix is refused on upload, including packages from another Macro Deck repository that were never published for plugins. - The manifest names its author, licence and repository.
publisher.nameinmanifest.jsonmust be the owner the plugin is published under in the Creator Portal: the Organization's name, or for a personal Project the creator's username (compared ignoring case). The Store always shows that owner, and an upload whose manifest names anyone else is refused.licensemust be set, at most 64 characters, as an SPDX identifier such asMIT; the Store shows it as the plugin's licence.repositorymust be the GitHub repository the plugin is built and released from, writtenhttps://github.com/<owner>/<name>; an upload from any other repository is refused.aimust be present and accurate (guidelines section 9): omitting it shows as "not declared" on the listing, and every flagfalsewith an emptyservicesis what states "uses no AI". The icon must visibly say "keyboard / hotkey" at small sizes, and any third-party icon keeps its licence credit in README.md. - The plugin passes the conformance suite on every platform it declares. The Store refuses a build
that does not come with a conformance report for each platform in
entrypoints, or whose report shows a failed Required check, never reached the plugin (MDC0201, the handshake, did not pass), or is for another plugin id or version. Thepublishjob in release.yml runs the suite on a stub host per platform and sends the reports, so never turn that off. Before calling a change done, runmacrodeck-plugin test(see "Verifying a change") and fix every Required failure; a failed Recommended check is allowed but worth fixing.
Report the result of this gate with every change: which version of the guidelines was checked (the
X-Creator-Guidelines-Version response header), and whether each of the six points holds.
- A plugin's identity is
manifest.jsonand nothing else.IPluginIntegrationcarries noId,Name,VersionorIsInitialized, and it never implementsIIntegrationIconProvider- both of those are the in-processIIntegration's surface. The builder's oldWithId/WithName/WithVersion/WithDescription/WithIconare gone; do not reintroduce them. - Ids you write in source are local ids -
^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$, max 64 chars, never containing::. The host derives the qualifiedintegrationId::localIdform from your authenticated registration. Never build or submit a qualified id yourself. - Action ids must be unique across the whole plugin, not just per integration. In process the key is
(integration id, action id); over the wire the owner is the plugin. A collision fails
Build(). - Event definition ids and variable
DefinitionIds are persisted in user data. Treat them as a public API: renaming one breaks every widget already bound to it.
- Leave
Program.csshaped as it is:CreatePlugin(args)→.UseMacroDeckLogging()→.UseLocalization(Strings.LocalizationCatalog)→.RegisterIntegration<T>()→Build()→RunAsync(). It is a realWebApplicationbuilder, soIHttpClientFactory, options binding, hosted services and DI are all available and preferred over hand-rolled equivalents. Extra registrations go onbuilder.ServicesbeforeBuild(). RegisterIntegration<T>()is the one door: it also registers the capability handler for every SDK interface the integration implements, so you never write a handler for a built-in capability kind. A genuinely new kind usesICapabilityHandler+RegisterCapabilityHandler<T>().services.AddMacroDeckIntegration<T>()is internal to the hosting package now and cannot be called from a plugin project. Registering the integration withservices.AddSingleton<T>()is an analyzer warning, not a workaround.Build()constructs every integration and handler as part of validation, so a constructor must be side-effect-free and cheap.- Never set your own listener URL. No
UseUrls, noConfiguration["urls"], noASPNETCORE_URLSinlaunchSettings.jsonorappsettings.json. The supervisor binds a port and starts probingGET /_macrodeck/healthbefore your process starts; overriding it makes the health check fail silently and permanently, with nothing surfaced to explain why. /_macrodeck/*is reserved. Mapping a route under it failsBuild().- The only writable location to rely on is
MACRO_DECK_PLUGIN_DATA_DIRECTORY. Anything written next to the executable lives in an immutable version directory and disappears on the next update or rollback. Build()reports every local problem at once in onePluginConfigurationException. Read the whole message before fixing anything; do not iterate one error per run.
InitializeAsync does not run at process start. It is gated on the connection being established, and it
runs again after a non-resume reconnect and whenever the host reports a configuration change. Make it
idempotent and safe to run repeatedly against an already-initialized process.
ActionResultmust be truthful.Success()claims the operation completed. Could not reach the provider, refused a permission, handed an unusable value →Failed(code, message)with the closestActionErrorCodesvalue. A press that did nothing must never report success.- A legitimate no-op is success: an optional parameter left blank, a repeat count of zero, a setting already in the requested state.
Acceptedis only for work the provider took but cannot confirm. Where the API can confirm, poll until it does rather than returningAccepted.- Throwing works (the flow engine records a failure) but the caller only ever sees a generic code - an
exception message is never sent to a client. Prefer an explicit
Failed. - A synchronous executor returns the cached
ActionResult.SucceededTaskrather than allocating. - Forward
context.CancellationTokeninto everything you await. Dropping it is MDP3001. - Parameter visibility (
OnlyWhen) is presentation only. The host still sends hidden parameters, so validate the combination in the executor - never infer anything from a field being hidden.
- No
.Result,.Wait(),.GetAwaiter().GetResult()orThread.Sleepanywhere in a type implementingICapabilityHandler,IActionExecutororIConfigFlow- the rule is whole-type, not just the interface methods, because the dispatcher has 32 concurrent invocation slots and a block anywhere reachable starves the rest (MDP3002). - No
async voidon an SDK contract type; an exception there kills the process instead of failing one call. The(object? sender, EventArgs e)handler shape is the one exception (MDP3003). - Invocations dispatch concurrently, each in its own DI scope. Instance state touched from more than one invocation needs its own synchronization - the same discipline as any concurrently invoked ASP.NET Core endpoint.
ICapabilityInvocationContextonly resolves inside an invocation scope. A singleton must not depend on it (MDP4001).
IEventPublisher.Publish, IUserNotifier.Notify/Dismiss and IPluginCatalogNotifier.CatalogChanged
never throw and are safe to call with no live session. Mirror that in your own wrappers: they are called
from websocket callbacks and poll loops where a throw would take the integration's own work down.
The round-trip host callbacks - context.Variables, context.Config, context.UserVariables,
context.Deck's mutating members, context.Scripts.RunAsync, context.Widgets.ApplyAsync - are the
opposite: real network calls that can throw HostInvocationException on rate limiting, timeout or no
connection. Handle them like any networked call. Deck.GetFolders(), Scripts.GetScripts() and
Widgets.GetWidgets() read a host-pushed cache instead and return empty in the short window before the
first push.
Every synchronous catalog-shaped member (GetInstances, EventDefinitions, DeclaredVariables,
GetProfiles, …) is served to the host from a cached describe, not a live call. When something outside
a host-initiated invocation changes what a later describe would answer - a config value
InitializeAsync just read, a device that appeared or vanished - inject IPluginCatalogNotifier and call
CatalogChanged(kind, reason). Skipping it leaves the UI disagreeing with the plugin until the next
reconnect. The host describes capabilities concurrently with InitializeAsync, so a first describe can
capture a default before the config read finishes.
- A config flow produces the entries;
InitializeAsyncreads them back throughIIntegrationContext.Config. Keep that division - the flow validates and persists, the integration consumes. - Persist credentials as
ConfigFlowValue.Secretso they land in the host's encrypted secret store. Never write a token to a plain string field, a log line, or a file of your own. Rotating credentials go back throughSetSecretAsync. - Never run your own OAuth redirect server. Return
ConfigFlowResult.External(url, resumeStepId)and let the host own the redirect and the callback correlation. - An integration that provides a config flow starts disabled until the user completes it. Everything else starts enabled.
- No user-facing literal. Every string a user reads is a key in
Localization/Strings.resx, reached through the generatedStringsclass: action names and descriptions, parameter labels, descriptions and placeholders,ActionStateDefinitionlabels, config flow step titles and field labels, event and variable metadata, issue text, and the message onActionResult.Failed/Accepted. A plainstringconverts toLocalizedTexttoo, so nothing stops you - which is exactly why this is a rule. ConfigFlowResult.Complete(title, …)is the one deliberate exception: it takes a plainstringbecause the host stores it as the configured entry's name and the user renames it from there. Write it in the default language and leave it.- Log messages and exception messages are diagnostics, not UI. They stay English literals; never localize a log template.
- Check
MacroDeckStringsbefore adding a key -Common.*,Validation.*,Connection.*,Settings.*are already translated everywhere Macro Deck ships. A duplicatedSaveis one more string every translator keeps in sync for text the reader already sees. Compose instead:MacroDeckStrings.Validation.Required(Strings.Actions.LogMessage.Message.Label()). - Keys are dotted, not underscored. A dotted key becomes a nested class, so
Actions.LogMessage.Message.LabelisStrings.Actions.LogMessage.Message.Label(). Name a key after where it is used, not after the English wording. A key that is also a group other keys nest under is MDLOC008. - Placeholders are named (
{host}) and become method parameters, so a missing one is a compile error. Positional{0}gives no such safety. A placeholder is astringunless a bracketed prefix on the entry's<comment>narrows it toint,long,doubleorbool. - A count-dependent sentence is one key:
[plural]on every form, keys suffixed.Oneand.Other,Otherrequired. The rule iscount == 1for every language and deliberately not CLDR, so phraseOtherto stay grammatical where that rule does not hold. - A translation is
Localization/Strings.<culture>.resxwith a well-formed BCP-47 name (de,pt-BR,zh-Hant-TW, never a truncatedzh, never an underscore). It needs only the keys it translates; the chain falls back requested culture → neutral → catalog default →en. Nothing is registered per language. Strings.resxitself is required even for a single-language plugin: it is what every translation is checked against and the last resource the chain tries.- The manifest's
languagesarray is derived bymacrodeck-plugin buildandpackfrom this folder. Hand-maintaining it is the same mistake as hand-maintainingfiles[]. - The generator's own diagnostics are MDLOC001-MDLOC008 (key only in a translation, placeholder
mismatch, duplicate key, bad parameter type, malformed culture suffix, removed
MacroDeckStringskey, broken plural family, key/group collision). Fix them; do not suppress them. - Dropping
UseLocalization(Strings.LocalizationCatalog)fromProgram.csdoes not fail the build. It fails at runtime, quietly, with every label rendering as[[plugin:<id>:Key]]. - The full reference is https://docs.macro-deck.app/sdk/localization/.
- Log through Serilog, not
Microsoft.Extensions.Logging. Inject Serilog'sILogger(and.ForContext<T>()), or useIntegrationLog.UseMacroDeckLogging()forwards everything to the host's log viewer. - The host stamps integration identity from the authenticated session - a plugin cannot set its own attribution, so do not try.
- Logging is rate-limited and a flood is dropped. For a poll loop that fails repeatedly, use
FailureEpisodeTrackerrather than a line per tick or a silent Debug-only degrade. - Structured properties reach the live viewer but are not persisted to the log file - put anything that must survive into the message template.
Directory.Build.propssetsNullable,ImplicitUsings,latest-recommendedanalysis,EnforceCodeStyleInBuildandCS8602as an error. Build warning-free; do not relax these to make a build pass.- C# in
src/is tab-indented. Match the surrounding file rather than reformatting it. - Suppress a diagnostic with the narrowest scope that fits and always with a reason on the
#pragmaor theNoWarnentry. - Comments explain non-obvious constraints - a race, a protocol rule, why a shape was chosen - not what the code already says.
- Write comments in English, regardless of the language used in chat or commit discussion.
- No decorative comment formatting - no ASCII dividers, banners, box-drawing, or emoji. A comment is a plain sentence, not a header.
- Keep comments as short as the constraint allows, and prefer no comment at all. Only write one for genuinely non-obvious or complex logic (a race, a workaround, a protocol quirk) - never to restate what a well-named method or property already says.
- Method and property names must be self-explanatory. If a name needs a comment to be understood, rename it instead of documenting it.
- No em dashes in code, comments, commit messages or documentation - use a period, comma or parenthesis instead.
- Follow DRY: extract shared logic instead of duplicating it across actions, providers or config flow steps. Do not extract on the first occurrence of similar code; do so once a real duplication pattern emerges.
- Keep a clean architecture: respect the separation between the integration, actions, config flow and provider-specific code. Don't reach across those boundaries or leak provider-specific types into shared SDK-facing surfaces.
- Consider performance: avoid unnecessary allocations, synchronous blocking, or repeated expensive work in hot paths (invocation handlers, poll loops).
- Dispose everything that owns unmanaged or long-lived resources (
HttpClienthandlers, subscriptions, timers, cancellation token registrations, websocket connections). PreferIAsyncDisposable/IDisposableand DI-managed lifetimes over manual lifecycle management. Watch for event-handler subscriptions that outlive their subscriber - a common source of memory leaks in long-running plugin processes.
Run the ones that apply, in this order, before calling a change done:
dotnet builddotnet testFor an interactive verification, start the installed Macro Deck desktop app and debug the plugin with
the Macro Deck - Real Host .NET launch profile. Keep the profile secret-free; supply a first-run
enrollment token only through the project's local .NET User Secrets and remove it after registration.
Do not add a second run configuration or a CLI/executable startup path.
macrodeck-plugin test --project src/SystemHotkeys --report markdown --output conformance.mdThe conformance suite drives a real session: capability contracts, invocation and cancellation semantics,
reconnect and resume, the reserved endpoints, logging limits. Exit 0 conformant, 1 the plugin is
wrong, 2 usage error, 3 input unreadable, 4 cancelled - 1 and 3 are deliberately distinct. Run
it after any change to capability shape, cancellation handling or the manifest, and treat a Required
check going from pass to fail as a blocking regression. Most checks SKIP until the plugin declares
capabilities.
The Macro Deck packages are pinned to an exact version in Directory.Packages.props rather than
floated, so the commands above need no version argument - see the comment on MacroDeckSdkVersion there
for why. Only to test against SDK surface that is not published yet, pack it into local-feed/ and
pass -p:MacroDeckSdkVersion=<version> - see "Building against a local SDK build" in
README.md.
macrodeck-plugin build --source src/SystemHotkeys --output ./artifacts
macrodeck-plugin inspect --artifact ./artifacts/<id>-<version>.macroDeckPluginbuild reads macrodeck-build.json, publishes each runtime identifier (win-x64, osx-arm64) into its
runtimes/<rid>/ slot and packs the result.
A dotnet build -c Release output is not packable: the manifest points at runtimes/<rid>/, which
only build assembles, so validate/pack against bin/Release/net10.0 fails on a missing entrypoint.
This plugin is framework-dependent: each entrypoint names runtimes/<rid>/SystemHotkeys.dll with
"runtime": { "kind": "FrameworkDependent", "dotnetVersion": "10.0" }, and macrodeck-build.json
publishes with --self-contained false -p:UseAppHost=false. Macro Deck ships a .NET 10 runtime (ASP.NET
Core included) with the host and runs the plugin on it, which is also why it appears as dotnet in
process lists. Going self-contained would mean removing the runtime block, pointing executable at
the apphost (SystemHotkeys.exe on Windows, SystemHotkeys on macOS) and publishing with --self-contained true - keep the
manifest and macrodeck-build.json on the same pairing, since mixing them fails validation.
Packing validates first, recomputes every files[] digest from disk and fills in languages from
Localization/, discarding whatever the source manifest declared - so never hand-maintain either.
Signing happens after packing, against the packed manifest; sign earlier and the digest will not
match.
- Work on a branch, not directly on
main. Usefeature/,fix/,refactor/,chore/,docs/orci/with a short kebab-case description, and an issue number where one exists. - Keep changes focused; no unrelated reformatting.
- Do not push or open a pull request unless asked.
- Do not add AI attribution or co-author trailers.
- Update
README.mdwhen the build, run, packaging or capability story changes. Update this file when a rule here stops being true.