Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
[2.5.1] - 2026-05-18
Added
- Docs site SEO infrastructure. The VitePress site at https://studnicky.github.io/yamete now ships a full SEO surface area: per-page canonical /
og:/twitter:meta viatransformPageData, JSON-LD blocks forSoftwareApplication+WebSite+Organization- per-page
BreadcrumbList, search-console verification slots (suppressed at build time until populated inpackage.json→yamete.seo),hreflangdeclarations, modernrobotshints (max-snippet:-1,max-image-preview:large), and a complete favicon stack withmanifest.webmanifestfor PWA-style install.
- per-page
- Sitemap + RSS feed.
sitemap.xmlis generated from every rendered page;feed.xmlis composed fromdocs/CHANGELOG.mdat build end and linked from the head via<link rel="alternate" type="application/rss+xml">so release watchers, Sparkle-style updaters, and GitHub-action notifiers can subscribe to version drops without polling. llms.txtindex. A semantic table of contents at/llms.txtsurfaces the docs structure to retrieval-augmented language models in the format proposed at https://llmstxt.org.- Release banner image. New
docs/public/og-image.{svg,png}(1200×630 social card, pink theme, wincing face with impact rays) drives the unfurl preview on Discord, Slack, iMessage, Twitter/X, and LinkedIn. Newdocs/public/yamete-banner.svg.templateis stamped per release byscripts/stamp-version.mjs(readsMARKETING_VERSIONfromproject.yml) intoyamete-banner.svgwith the version pill visible; the release workflow now prepends this banner at the top of every GitHub release body via araw.githubusercontent.comURL. - GitHub repository social preview. New
docs/public/github-social.{svg,png}(1280×640, GitHub's recommended Repository → Settings → General → Social preview dimensions). Upload the PNG via the repo settings UI to control the unfurl card that shows whengithub.com/Studnicky/yameteitself is shared on Discord / Twitter / Slack / iMessage. GitHub provides no API for this surface; the asset lives indocs/public/for convenient access. - Docs-build CI gate.
ci.ymlgains adocs-buildjob that runsnpm ci,stamp-version:check,docs:build, and asserts the SEO outputs (sitemap, feed, robots, manifest, OG image, stamped banner) exist indocs/.vitepress/dist. Docs regressions are now caught at PR time instead of after merge to master.
Fixed
- GitHub Pages deploy was failing since release 2.3.0 due to bare
*<display>*placeholders indocs/using.mdanddocs/CHANGELOG.mdwhich Vue's SFC parser read as unclosed HTML start tags during VitePress'svite-plugin-vuepass. Replaced with<display>entity escapes so the markdown italicises the placeholder without triggering the HTML tokeniser. The newdocs-buildCI gate (above) prevents this class of regression from reaching master again. - VitePress build no longer chokes on local-only design notes. The gitignored
docs/art/design-exploration markdown is now declared insrcExcludeso VitePress's srcDir glob skips it. Previously the files were silently picked up by local builds and could throw interpolation parser errors onbrace pairs unrelated to the shipped site.
Build & release pipeline
stamp-version.mjsscript readsMARKETING_VERSIONfromproject.yml(the canonical macOS bundle version source of truth) and stamps everydocs/public/*.svg.templateinto a sibling.svgwith__VERSION__replaced byv<version>. Hooked intopredocs:build.--checkmode exits non-zero if any output is stale so CI flags pre-tag drift.render-og.mjsscript rasterisesog-image.svgandyamete-banner.svgto PNG viasharp(devDep). Hooked intopredocs:buildafter stamp.release.ymlbanner verification step greps the stampedyamete-banner.svgfor the version string and fails the workflow if it doesn't match the tag — surfaces missednpm run stamp-versioninvocations before the release notes go out.
[2.5.0] - 2026-05-07
Added
- Independent Caps Lock LED and Keyboard Brightness toggles. The Reactions group renders two separate accordion cards instead of one bundled "Keyboard" card, and the per-event matrix in Stimuli has a dedicated column per output (KbBright + Caps). Each output is gated by its own
LEDOutputConfigflag insideLEDFlash.actionso a user can enable the keyboard backlight without firing the Caps Lock LED, or vice versa. The Keyboard Brightness card hides on hosts where the backlight isn't available (Mac mini, Mac Pro, etc.). - Per-model coverage tables in every sensor source header.
LidAngleSource.swift,GyroscopeSource.swift,AmbientLightSource.swift, andHeadphoneMotionAdapter.swiftnow document expected reach across M1 → M4 silicon and headphone models. The runtime probe is the source of truth; the tables are the documentation.
Changed
- Lid angle now reads the dedicated Apple lid HID device, not the SPU IMU stream. Vendor
0x05AC, Product0x8104, UsagePage0x0020, Usage0x008A, Feature Report 1 polled at 30 Hz, decoded asUInt16 LEwhole degrees from bytes [1..2]. The previous SPUusagePage 0xFF00 / usage 8subscription was decoding uninitialized tail bytes — the IMU report is exhausted by accel/gyro at offsets 6/10/14, with no lid channel anywhere in the buffer. M2 Pro/Max, M3 family, and M4 family laptops have the dedicated device; M1 (any), M2 base MBA / 13" MBP, and every desktop have either no device or no IMU silicon at all, and the toggle hides accordingly. - Ambient light corrected from
usage 7tousage 5. The M4 Max ioreg dump and AsahiLinux's reverse-engineering both confirm ALS rides onusagePage 0xFF00 / usage 5. The7was a guess that worked only because the SPU broker fans every report regardless of usage tuple —usage 7does not exist on the SPU bus. - Gyroscope and ALS gain App Store sandbox parity with the accelerometer.
isAvailablenow combines device presence with a runtime activity probe via_last_event_timestampon thedispatchGyroanddispatchAlsservices respectively. On Direct builds the probe is bypassed (the unsandboxed process can write the activation properties directly); on App Store builds the probe must report fresh-streaming for the toggle to surface. A one-shotactivelyReporting=log fires at every source start so silent failures are visible in the audit log. yamete-sensor-kickstartnow activates gyro and ALS alongside accel. The helper previously short-circuited theAppleSPUHIDDriveriterator ondispatchAccel, leaving the gyro and ALS kernel-driver instances silent on App Store builds. The iterator now walks every service flagged with any ofdispatchAccel,dispatchGyro, ordispatchAlsand writes the activation triplet to each. Existing helper installs need a one-timesudo ./install.shto pick up the change.AccelHardware.isSensorActivelyReportingis now parameterised overdispatchKeyso gyro and ALS can mirror the accel probe. The probe also no longer bails on the first stale sibling — it scans the entireAppleSPUHIDDriveriterator and returns true if any matching service is fresh.
Fixed
- Lid sensor no longer fires nonstop
LidSlammedreactions. Field audit logs from this release showed 3 500+lidSlammedevents per hour with physically impossible angles (-322°, -244°, etc.) — the consequence of the offset-18 garbage decode driving the slam-rate state machine into a loop. The dedicated-device rewrite produces real angles in the[0°, 180°]range, and a defensive sanity gate rejects anything outside that envelope. keyboardBrightnessEnabledis now wired into pipeline rebuilds. Previously the impact pipeline'sshouldRunpredicate did not listkeyboardBrightnessEnabled, so flipping it on alone (with every other output off) would not start sensors. The setting is now observed byYamete.startSettingsObservationand included inrebuildSensorPipeline.shouldRun.- 40 lprojs synchronised with the new keyboard/Caps Lock string keys.
setting_led_enabled,setting_keyboard_leds,help_keyboard_leds, andlegend_ledare dropped (replaced bysetting_keyboard_brightness,setting_caps_lock_led,help_keyboard_brightness,help_caps_lock_led,desc_caps_lock_led,legend_keyboard_brightness,legend_caps_lock). Non-en locales receive English copy as a fallback for translator follow-up.
[2.4.0] - 2026-05-06
Added
- Yamete+ rebrand for the Direct download. The notarised Developer-ID-signed download is now named Yamete+ instead of "Yamete Direct". The bundle is
Yamete+.app, the executable isyamete-plus, the bundle identifier iscom.studnicky.yamete.plus, the DMG asset isYamete+.dmg. The App Store variant keeps its existingYamete/com.studnicky.yameteidentity. The menu's header title now reads "Yamete+" with a pink+glyph for Direct builds; App Store renders an unmarked "Yamete". The previous footer build-variant pill is gone — the header carries the indicator. - Footer reorganisation. Footer cells render as a single priority-ordered list flowing column-major into a 2-column grid: Debug Logging (Direct only) → Launch at Login → Version → Reset Settings → Info Links → Quit. With Debug Logging absent (App Store), Launch at Login slides into the top-left slot. The central inter-column divider that read as a table border is gone.
Changed
- Auto-update path from "Yamete Direct" to "Yamete+". Existing pre-2.4.0 installs auto-update through their legacy
Yamete.Direct.dmgasset name, which the 2.4.0 release publishes as a transitional alias alongside the canonicalYamete+.dmg. After the update installs, a one-shot bundle-relocation step at app launch detects the legacy/Applications/Yamete Direct.apppath, copies the bundle to/Applications/Yamete+.app, removes the legacy bundle, and relaunches from the canonical path. UserDefaults migrate fromcom.studnicky.yamete.directtocom.studnicky.yamete.pluson first launch under the new identifier — every existing setting carries over. Input Monitoring TCC grants do not carry over (macOS scopes them per-bundle-identifier); the diagnostics row surfaces the missing-permission state with an "Open System Settings…" deep-link the moment a feature that needs it is used. - List containers drop their wrapping background. The faint grey rounded-rect around DeviceToggleList, SelectionList, and NotificationLocalePicker is gone; per-row dividers carry the "this is a list" affordance. Diagnostics pills lose their stroke for the same reason — the tinted fill is enough.
- Pre-push hook auto-refreshes host-app snapshot baselines. New
scripts/refresh-host-app-snapshots.shruns only for pushes torelease/*andhotfix/*branches; it wipes the sandbox-mirror at~/Library/Containers/com.studnicky.yamete/Data/tmp/yamete-snapshots/HostApp/, invokesmake test-host-apptwice (recording iteration tolerates the standard ".missing"-mode failures, verification iteration must pass clean), and syncs freshly-recorded baselines back toTests/__Snapshots__/HostApp/SnapshotUI_Tests/. If the source tree moved, the push is refused with a clear "stage and commit these and re-push" message. Feature branches and CI self-skip.
[2.3.0] - 2026-05-06
Added
- Diagnostics row. New surface in the menu directly below the header that aggregates active warnings/errors (paused state, missing Input Monitoring permission, sensor errors). Hidden when no diagnostics are active. The "paused" indicator that previously sat inside the top header row moved here so the header is back to a clean 2x2 grid (title | impacts; subtext | last-impact).
- Input Monitoring permission sentinel. Yamete now consults
IOHIDCheckAccessat launch and on app foreground. When permission is missing (most commonly becausemake install's ad-hoc resign changed the bundle's cdhash and macOS silently revoked the prior grant), the diagnostics row surfaces "Input Monitoring required" with an "Open System Settings…" button that deep-links to the Privacy & Security pane. The previous failure mode was silent — no mouse clicks, no keyboard typing, no Caps Lock LED, with only a single warn-level log line as evidence. InputMonitoringAccesshelper (Sources/YameteCore/) consolidates the three previously-inline TCC checks (MouseActivitySource,KeyboardActivitySource,RealLEDBrightnessDriver) onto a singlestatus()/requestIfUnknown()API. Sources never prompt; onlyYamete.bootstrap()does, matching macOS's "one prompt per process lifetime" semantics.- Active output only toggle for audio. Mirrors the existing active-display-only toggle: when on, impact sounds route to the system default audio device only, ignoring the per-device selection. Hidden when ≤1 audio device is selected (same gating rule applied to the active-display-only toggle for consistency). Useful when the user wants the sound to follow "wherever I'm listening right now" instead of fanning out across configured outputs.
- Audio transport class tagging. Each audio output row in the menu now renders an icon derived from
kAudioDevicePropertyTransportType:laptopcomputerfor built-in speakers,tvfor DisplayPort/HDMI,cable.connectorfor USB,headphones.bluetoothfor wireless,headphonesfor analog jack,bolt.horizontalfor Thunderbolt,hifispeakerotherwise. Lets the user tell at a glance which outputs are monitor speakers vs USB headsets vs wireless devices. - EDID-based monitor↔speaker pairing. Audio devices that ride on a display's video cable (DisplayPort, HDMI, MacBook built-in) now get paired to the display they belong to. The pairing reads the standard EDID identifier triple (vendor, product, serial) from the audio device's IORegistry ancestors and matches against
CGDisplayVendorNumber/ModelNumber/SerialNumber. Built-in speakers pair structurally to whatever display reportsCGDisplayIsBuiltin == 1. Audio rows render an "attached to <display>" footnote when pairing succeeds. See docs/architecture/display-audio-pairing for failure modes (USB-C docks, KVMs, adapter chains, monitors with zero-EDID-serial).
Changed
AppleSPUDevicelifecycle race fix. The multi-subscriber broker for the SPU HID device serialised concurrent open/close transitions through a newtransitionLock. Previous behaviour: when the four initial subscribers (accel, gyro, ALS, lid) raced into the refcount-zero open path, the loser's cleanup calledSensorActivation.deactivate()on the winner's still-live session, killing the report stream after one report and tripping the 5-second watchdog. The fix eliminates the race-loser teardown path entirely; there is now exactly one IOKit lifecycle operation in flight at a time across the broker. New regression testtestConcurrentSubscribes_openDeviceCalledExactlyOncepins the invariant.- Active-display-only toggle visibility. The toggle now only renders when ≥2 displays are selected. With 0 or 1 displays selected the toggle adds no signal and was UI noise. The audio side gets the same treatment.
- Header layout simplification. The top bar's centre column (which held the conditional "Paused" pill) is gone. Header is now a clean 2x2: title and impacts counter on the first row, rotating subtext and last-impact tier on the second. The paused indicator surfaces in the diagnostics row instead.
Fixed
MainActormentions in commit messages and prose. All future mentions in markdown / prose contexts useMainActorwithout the@prefix to avoid GitHub's @-mention parser indexing an unrelated user. Source code (*.swift) keeps the@MainActorattribute as required by the Swift compiler.
[2.2.0] - 2026-05-05
Added
- Thermal sensitivity ratchet. New
thermalRatchettunable gates the sensitivity of thermal state transitions to prevent hair-triggering during power/load transients. Configurable per-state thresholds (nominal, fair, serious, critical) with separate warmup and cooldown windows.
Changed
- Documentation reorganization. ARCHITECTURE and CONFIGURATION guides split into dedicated pages in
docs/. README streamlined to link to full documentation. - Diagram enhancements. Architecture diagrams now support zoom and improved visual clarity for large sensor topology graphs.
Fixed
- Lint configuration alignment. ESLint and TypeScript strict checks unified across the build system.
[2.1.1] - 2026-05-04
Fixed
- Release workflow now reads
docs/CHANGELOG.mdinstead of the pre-reorg rootCHANGELOG.mdpath. The v2.1.0 tag's release run failed at the changelog-extract step (awk: can't open file CHANGELOG.md) and never published the GitHub Release with the DMG attached. CI-only fix; no app behaviour changes between v2.1.0 and v2.1.1. v2.1.1 is the version that actually ships a published release.
[2.1.0] - 2026-05-04
Added
Four new sensor surfaces, each with full menu controls and per-reaction matrix integration. Following gap analysis against four reference repos (
olvvier/apple-silicon-accelerometer,pirate/mac-hardware-toys,harttle/macbook-lighter,vlasvlasvlas/spank), Yamete now publishes:- Gyroscope spikes (
.gyroSpike) — lid yank, laptop spin, rotation transients. Same Apple Silicon SPU HID device as the BMI286 accelerometer, different HID usage (9 vs 3). Six-gate consensus pipeline (GyroDetector) tuned for deg/s magnitude with separate spike threshold, rise rate, crest factor, confirmation count, warmup, and cooldown. - Lid angle state transitions (
.lidOpened,.lidClosed,.lidSlammed) via SPU HID usage 8. State machine (closed/opening/open/closing) with EMA smoothing on Δangle/Δt to suppress jitter. The slam gate fires when rate-of-change crosses a configurable negative threshold while the angle approaches zero. - Ambient light step changes (
.lightsOff,.lightsOn,.alsCovered) via SPU HID usage 7. Two-second ring buffer + step detector with separate percent and floor/ceiling gates per direction. The.alsCoveredpath has its own faster rate constraint so a hand over the sensor reads distinctly from gradual room dimming. - Thermal pressure transitions (
.thermalNominal,.thermalFair,.thermalSerious,.thermalCritical) viaNSProcessInfo .thermalStateDidChangeNotification. Cold-start suppression: the initial state captured at start does not publish. Per-state dedup. No tunable thresholds — the levels are OS-defined.
- Gyroscope spikes (
AppleSPUDevice broker. Refactored the previously-direct accelerometer IOHID handle into a ref-counted multiplexer with a single input-report callback that fans out to every subscriber regardless of
(usagePage, usage)— gyroscope, lid, and ambient-light sources subscribe alongside the accelerometer and decode their own byte offsets from the shared report. Phase 0 of the v2.1.0 plan. The activation/deactivation three-phase teardown invariant is preserved verbatim from the previous implementation; only the LAST subscriber's release closes the device.Two emission patterns documented. Continuous-stream sources (accel, microphone, gyroscope, ambient light) run a detector pipeline over a high-frequency sample stream and emit one Reaction on threshold cross; discrete state-transition sources (USB, power, audio, BT, Thunderbolt, display, sleep/wake, lid, thermal) observe an OS notification surface and emit one Reaction per user-meaningful state change. Both feed the same bus.
Menubar redesign. Header now puts the static "Yamete" title and face art on the left with a body-only rotator strip; impact counter folded into the header rows; paused indicator centred between title and counter and re-renders on fusion-state change. Master kill-switch row per group drives a 35→60% dim of the group's contents via semantic color roles. Four group accordions (impact / device / response / sensor) default to collapsed. Header rotator pulls spicy moans from the Direct overlay with non-repeating shuffle. Impact-detection promoted to its own master accordion with Impact Consensus rendered below the threshold row.
Impact Consensus relabel. "Sensor Consensus" → "Impact Consensus" across all 40 lprojs; help text and unit copy follow. The consensus slider only governs impact-sensor fusion (accelerometer, microphone, AirPods motion); with eleven event sources in v2.1.0 — most discrete state-transition — the original label was misleading.
Active-above-inactive stimuli sort.
StimuliSectionpartitions enabled stimuli above disabled, alphabetised within each group by localised title. Toggling a stimulus relocates it on the next render. Impact-sensor consensus group is composed inSensorSectionaboveStimuliSectionand is pinned by composition, not by sort.App icon regenerated. New 10-resolution
AppIcon.appiconsetrendered from the canonical face SVG: pink rounded bezel + white face. Replaces the inherited template icon.Observation contract tests. New
Tests/ObservationContractTests.swiftasserts that view-consumed Observable types stay coupled to SwiftUI re-render — caught at test time instead of "the menu didn't update."
Changed
- Event source count documented as eleven (was: seven).
- Mutation catalog grew from 111 → 130 entries (5 gyro, 5 lid, 5 ALS, 4 thermal). All caught.
- README slimmed; ARCHITECTURE / CHANGELOG / LICENSES-CONTENT / PRIVACY moved into
docs/. - Two passes of historical/temporal comment scrubbing across SensorKit, YameteApp views, and Tests.
Fixed
- Paused indicator now re-renders on fusion-state change (was static after first paint).
[2.0.0] - 2026-05-02
Added
Reaction Bus architecture. Single multi-subscriber
ReactionBusactor inYameteCorewithbufferingNewest(8)per subscriber. Sources publish onto the bus; outputs subscribe independently and pattern-match the unifiedReactionenvelope.Reaction.impact(FusedImpact)is just one case alongside cable/power/device events — the case IS the contract, no nested payload type, no dispatch table, no event-router. Adding a new event class is one newReactioncase + one new source class + the compiler tells every output what it now needs to handle.LED flash output. New
LEDFlash(Sources/ResponseKit/LEDFlash.swift) pulses the keyboard's Caps Lock LED on every reaction the user has enabled, gated through the same intensity envelope asScreenFlash. Brightness is PWM-dithered at 60Hz against the sharedEnvelopesince Caps Lock is binary on/off. Caps Lock state is captured before the pulse and restored after — there is a brief window during which Caps Lock toggles rapidly. Joke app, accept the inaccuracy. Toggleable in the new LED Flash menu bar section with brightness min/max sliders. No new entitlements required (uses existingcom.apple.security.device.usb).Cable / power / device event sources. Seven new IOKit / CoreAudio / IOPS notification sources publishing onto the bus:
USBSource— IOServiceMatchingkIOUSBDeviceClassName, suppresses initial replay burst, debounces vendor/product 50ms.PowerSource—IOPSNotificationCreateRunLoopSource, edge-triggered on AC connect / disconnect transitions.AudioPeripheralSource—AudioObjectAddPropertyListeneronkAudioHardwarePropertyDeviceswith set diffing for per-device events.BluetoothSource— IOServiceMatchingIOBluetoothDevice(pure IOKit path, avoids privateIOBluetooth.frameworksymbols).ThunderboltSource— IOServiceMatchingIOThunderboltPort.DisplayHotplugSource—CGDisplayRegisterReconfigurationCallback, debounced 200ms to collapse macOS's 3–4 callbacks per real change.SleepWakeSource—IORegisterForSystemPowerforwillSleep/didWake(same API the sensor-kickstart helper uses to re-warm the BMI286 on wake). Per-event default intensities live inReactionsConfig(single tuning surface). All seven sources ship enabled by default; user toggles each on/off in the new Cable & Power Events menu bar section.
Per-(output × event) toggle matrix. Each of the four configurable outputs (sound, screen flash, notification, LED) has an independent per-
ReactionKindenable/disable toggle persisted as a JSON-encodedReactionToggleMatrix. The user can have sound-only on USB attach, flash-only on AC unplug, notification-only on display reconfiguration, etc. — fully orthogonal across outputs.Events.stringsnotification table. NewApp/Resources/en.lproj/Events.stringswithtitle_<kind>_<n>/body_<kind>_<n>pools per reaction kind, mirroring the existingMoans.stringsnumbered-suffix convention. Loader caches the table separately so impact-pool clears don't blow it away.com.apple.security.device.bluetoothentitlement added to the App Store build forIOBluetoothDeviceIOService matching.FiredReactionenriched event envelope.ReactionBusnow resolvesclipDuration,soundURL,faceIndices(one per connected display), andpublishedAtexactly once before fan-out via a registeredReactionEnricherclosure. All subscribers receive identical pre-resolved values — no per-output duration math, no duplicate face/clip selection. Enricher has a 0.5 s timeout with a safe fallback and a set-once precondition to prevent post-bootstrap replacement.FaceLibraryshared face cache. Centralized face image cache and per-event recency dedup scoring (selectIndices(count:)). Called once by the enricher;ScreenFlashandMenuBarFaceboth look up by index. Appearance- aware (reloads on dark/light mode change). Replaces the two independent caches that were inScreenFlashandMenuBarFace.Per-display face matching.
FiredReaction.faceIndices[i]maps toNSScreen.screens[i].ScreenFlashshows a different scored face on each display;MenuBarFaceshowsfaceIndices[0](primary display). AddedfaceIndex(for:)bounds-safe accessor to handle display count changes between enrichment and rendering.Keyboard brightness spring animation in
LEDFlash.KeyboardBrightnessClient(CoreBrightness private framework) controls keyboard backlight. Animation uses a damped spring centred on the user's current brightness level so it never drives the backlight to zero. Includes: idle-dimming suspension, launch-time brightness snapshot with crash-recovery sentinel file (kb_dirty), andvalidateArgCountguard on everyunsafeBitCastdispatch toKeyboardBrightnessClientselectors.EventSourceprotocol. Separate fromSensorSource— the seven infrastructure event sources (USBSource,PowerSource, etc.) now conform toEventSourcerather than the impact-sensorSensorSource.ImpactFusionConfigrename.FusionConfigrenamed toImpactFusionConfigfor naming consistency withImpactFusionand the*OutputConfigfamily.Yamete.shutdown()and app-quit cleanup.applicationWillTerminate(_:)callsyamete.shutdown(), which cancels all output tasks, stops the fusion pipeline, closes the bus, and callsledFlash.resetHardware()to restore keyboard brightness before process exit.Canonical docs source system.
docs/_includes/what-it-does.mdanddocs/_includes/under-the-hood.mdare the canonical prose for the project description; HTML pages and README reference them.make docs-checkvalidates all source file references indocs/*.htmlat lint time.docs/_config.ymlenables Jekyll on GitHub Pages without changing the visual design.New test files.
FiredReactionTests,BusEnricherTests,ReactionsConfigTests(exhaustiveness check foreventIntensitymap).Haptic feedback output. New
HapticResponderfires rapid Force Touch haptic pulses on each reaction. Pulse density scales with theEnvelopelevel — dense at the attack peak, sparse through the decay tail. Intensity is a user-adjustable multiplier (0.5×–3.0×). Works on any Mac with a Force Touch trackpad. No entitlements required.Display brightness flash output. New
DisplayBrightnessFlashspikes the main display's brightness above the user's current level on hard impacts, then restores it over theEnvelopefade window. Peak = current + user-configured boost × intensity. UsesDisplayServicesGetBrightness/DisplayServicesSetBrightnessloaded at runtime viadlopen(private framework, no entitlements required). Gated by a minimum-intensity threshold.Screen tint output. New
DisplayTintFlashbriefly tints the display pink by crushing the green and blue gamma channels viaCGSetDisplayTransferByTable(public CoreGraphics). Tint depth scales withEnvelopelevel. Automatically skipped on macOS 26+ where the gamma table path is unreliable. No entitlements required.Volume spike output (Direct build only). New
VolumeSpikeRespondertemporarily raises system output volume to a target level on hard impacts, then restores the original volume after the reaction window. UseskAudioHardwareServiceDeviceProperty_VirtualMainVolumevia AudioObjectAPI. Gated by a minimum-intensity threshold. Compiled only with#if DIRECT_BUILD.Trackpad activity event source. New
TrackpadActivitySource(EventSource, not part of impact fusion) usesNSEvent.addGlobalMonitorForEventswith a sliding RMS window to detect two patterns:trackpadTouching(sustained scroll/gesture activity) andtrackpadSliding(high-velocity scrolling). Both publish directly to theReactionBus, independent of the impact pipeline. Configurable window duration and activity threshold. Enabled by default. Tunable via the new Trackpad Tuning accordion in the menu bar.Per-(output × event) matrix for new outputs. Haptic, display brightness, display tint, and volume spike each have their own
ReactionToggleMatrixpersisted in UserDefaults. Event matrix rows in the menu bar UI now show two rows of output toggles: core (audio, flash, notification, LED) and hardware (haptic, brightness, tint, volume spike).Trackpad Tuning section. New collapsible accordion in the menu bar (shown when Trackpad Activity source is enabled) with sliders for activity window duration, sensitivity threshold, and intensity scale.
Test infrastructure overhaul. Multi-layer test suite landed alongside the reactive-output pipeline:
- Onion-skin Ring 1 / Ring 2 architecture for the matrix bus tests. Ring 1 cells exercise pure logic with mock outputs; Ring 2 cells round-trip through a real
ReactionBuswithMatrixSpyOutputfixtures and assert cross-source interleavings, drop-not-cancel semantics, and coalesce fan-out invariants. - Mutation runner with 111 catalog entries.
make mutatewalksTests/Mutation/mutation-catalog.json, applies each mutation to its target source file in-place, runs the suite, and asserts the mutation is caught by at least one failing test. Per-target slice runner (scripts/mutation-test-slice.sh) drives the GitHub ActionsMutation catalog (PR slice)lane. - Property-based fuzz cells (
Tests/Property_*Tests.swift) — bus invariants over randomised stimulus streams, locale × plural fuzz against the localisation matrix, settings-corruption fuzz againstSettingsStore, and state-machine model checks for the lifecycle state transitions. - Concurrent-interleaved fuzz that publishes from multiple sources on overlapping timelines and asserts no double-action / no missed drop / no leaked task across a randomised interleave space.
- ~28 SwiftUI snapshot baselines per build variant across four variants (
AppStore,Direct,HostApp,CI) covering header, device, response, footer, accordion-card row counts, theme palette swatches, trackpad-tuning expand/collapse, and (Direct) headphone / accel tuning. Records viaSNAPSHOT_RECORD_MODE=true; reads viaprecision: 0.99 / perceptualPrecision: 0.98to tolerate antialiasing drift. - Performance baselines (
Tests/Performance/baselines.json) with absolute thresholds and amake perf-baselineregression gate (tolerance_factor: 2.0×default). - Driver Real-vs-Mock parity tests that run the same matrix against the production driver and the mock and assert byte-equal outputs.
- Crash-handling boundary audit and cross-boundary fault injection cells that verify each Source / Bus / Output boundary surfaces typed errors rather than silently swallowing them.
- CI-tolerance helpers (
Tests/Helpers/AwaitUntil.swift) withCITiming.envelopeMultiplier(3× under CI),awaitUntil(...)polling primitive, andskipIfCIBaselineMissing(...)snapshot bootstrap. Detects CI via either theCI=trueenv var or a/Users/runner/bundle-path prefix (xcodebuild does not always propagate the env var into the test bundle). - Host-app xcodebuild lane (
make test-host-app,.github/workflows/host-app-test.yml) that runs the YameteHostTest scheme inside a realYamete.appbundle so cells gated on UN center, full Haptic engine, CGEvent.post under Accessibility, and Force-Touch trackpad probes execute their Real-driver halves rather than skipping under SPMxctest. - Snapshot-baseline-seed workflow (
.github/workflows/snapshot-baseline-seed.yml) — workflow_dispatch helper that records macos-15-runner-rendered baselines underTests/__Snapshots__/CI/and opens a PR adding them, addressing runner-vs-developer-host pixel drift.
- Onion-skin Ring 1 / Ring 2 architecture for the matrix bus tests. Ring 1 cells exercise pure logic with mock outputs; Ring 2 cells round-trip through a real
Changed
SensorAdapterprotocol →SensorSourceprotocol. Three concrete implementations renamed:SPUAccelerometerAdapter→AccelerometerSource,MicrophoneAdapter→MicrophoneSource,HeadphoneMotionAdapter→HeadphoneMotionSource. Public surface preserved.ImpactFusionEngine→ImpactFusion. Now owns the multi-source task fan-in lifecycle (start/stop) and publishesReaction.impactonto the bus. The oldingest(_:activeSources:)API is preserved for tests; runtime path uses the engine's internalactiveSourcesstate.ImpactControllerdeleted. The router-style controller dissolved into three pieces: sensor lifecycle moved into each*Sourceclass, fusion is nowImpactFusion, response dispatch became per-outputconsume(from:configProvider:)loops on eachResponseKitoutput. The newYameteorchestrator (Sources/YameteApp/Yamete.swift) owns the bus, sources, fusion, outputs, and lifecycle wiring — no routing.AudioResponder/VisualResponderprotocols deleted. Outputs are now concrete classes withconsume(from: ReactionBus, configProvider:)methods — no shared protocol because there's no polymorphic dispatch surface.- Menu bar reaction face moved to
MenuBarFace(Sources/YameteApp/MenuBarFace.swift), an@Observableclass that subscribes to the bus and updates its ownreactionFace/lastImpactTier/impactCountstate.StatusBarControllerreads fromyamete.menuBarFace.reactionFaceinstead ofcontroller.reactionFace. - Sensitivity gate is now
FusedImpact.applySensitivity(...)(a static method inYameteCore). The orchestrator installs it onImpactFusion.intensityGateso the bus pipeline applies the user's sensitivity band before any output sees the impact.
Fixed
NSSoundlifetime.AudioPlayerretainsNSSoundinstances inactiveSounds: [NSSound]and releases them only insound(_:didFinishPlaying:). Previously sounds were deallocated immediately after.play(), silently truncating playback.- IOKit NULL device guard in
LEDFlash.writeLED.IOHIDElementGetDevicecan return nil when the keyboard disconnects mid-animation. Added a service- port validity check before callingIOHIDDeviceSetValue. IOHIDManagerclosed on deallocation.LEDFlash.deinitnow callsIOHIDManagerCloseso the kernel-side manager reference is released promptly rather than waiting for process exit.IONotificationPortiterator cleanup on partial setup failure. USB, Bluetooth, and Thunderbolt event sources now release any already-created IOKit iterators in the failure branch ofstart()before destroying the port.- IOKit context lifetime in event sources. USB, Bluetooth, and Thunderbolt sources switched from
passUnretainedtopassRetainedcontext pointers, with explicitrelease()instop()after iterator release, matching the accelerometer pattern. ReactionBusenricher set-once precondition.setEnricher(_:)now assertsself.enricher == nil; calling it twice is a programmer error.LogStorefile I/O errors surfaced.createDirectory,createFile, andFileHandle(forWritingTo:)failures now log toos.Loggerdirectly rather than silently no-op.ReactionToggleMatrixencode/decode failures logged. Previously returned empty data / empty matrix silently; now logs at.errorlevel.- Consensus clamping logged. When
consensusRequiredexceeds active source count, the effective value and reason are now logged at.info. HeadphoneMotionAdapterprobe timeout weak capture. Changed from strong[self]to[weak self]in theasyncAfterclosure, preventing the adapter from outliving its expected lifetime if deallocated before the 0.4 s probe fires.
[1.3.2] - 2026-04-17
No source-code changes since 1.3.1 — the shipped app binary is behaviorally identical. This release cuts a tag so the accumulated docs, Pages, and CI-hygiene work reaches the Pages site and the repo's default-facing README.
Added
- Dependabot config for GitHub Actions dependencies — PR #40.
.github/dependabot.ymlschedules weekly action-version updates with minor + patch bumps grouped into a single PR. Swift Package Manager is not yet a Dependabot ecosystem (tracked upstream at dependabot/dependabot-core#7268) so SPM deps inPackage.swiftstill require manual bumps. - Workflow-YAML linting via actionlint — PR #40. New
actionlintjob inci.ymlusingraven-actions/actionlint@v2catches syntax and shellcheck-level bugs in workflow YAML before a push lands on a runner. Caught a real SC2129 inrelease.ymlon its first run. - App icon + an intentionally silly number of badges in the README — PRs #43, #44. The
AppIcon.appiconset128x128 asset renders centered at the top of the README at 160px. Thirty-eight shields.io badges across six themed rows: ship status, platform + stack, what's inside, promises, flex, and a row of CI-shaped exclamation badges for things that are decidedly not CI status (current face, last impact tier, watchdog status, machine spirits, etc.).
Changed
- README redesigned: voice match Pages, link instead of duplicate — PR #43. The README was repeating content that lives more comfortably on
studnicky.github.io/yamete(How it works, Configuration, Distribution, Project structure, the ASCII detection diagram). The rewrite matches the project site's voice, sends visitors to the right Pages section via a link list at the top, and trims 157 lines down to ~70 + a badge wall. Single source of truth per topic lives on Pages; the README orients visitors. - User-facing docs refreshed to present tense — PR #43. Scrubbed phrasing about what things "used to be", "was renamed", "now ships" across README,
docs/architecture.html,docs/support.html,docs/INSTALLATION.md. Bumpeddocs/assets/sidebar.jsversion badge to 1.3.2 so every published page surfaces the current release consistently. - CI workflow concurrency groups — PR #40. Rapid-fire pushes on non-
masterrefs now cancel stale in-flight CI runs instead of stacking them behind each other. Release workflow uses the group for observability only (never cancels a tagged build mid-run). - Release workflow opted into Node.js 24 — PR #40. Node.js 20 is scheduled for removal from GitHub-hosted runners on 2026-09-16.
softprops/action-gh-release@v2currently runs on Node 20; settingFORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"at the workflow env level migrates ahead of the forced cutover.
Fixed
- Release notes were three separate
echo >> fileappends instead of a grouped redirect — PR #40 follow-up. actionlint/shellcheck flagged the pattern as SC2129 on its first run; consolidated the three appends and theawkinvocation into a single `file` block. Same output, one syscall instead of four.
[1.3.1] - 2026-04-17
Fixed
MicrophoneAdapterteardown crashed the test process under certain CoreAudio states (SIGSEGV) — PR #38. Observed on CI run 24548266785 on the 1.3.0-bounddevelop, but the underlying race was latent regardless of release.- Teardown order in
continuation.onTerminationwas reversed:inputNode.removeTap(onBus: 0)ran BEFOREengine.stop(). Removing a tap while the engine is still running lets the audio thread fire one more buffer callback that dereferences state captured by the tap closure after it has been torn down. Correct order is stop first (blocks until pending audio-thread callbacks drain) then remove. Inline comment documents the invariant. - Added a format-validity gate on
inputNode.outputFormat(forBus: 0). Hosts with no real audio input (headless CI runners, containers, virtualized macOS) can return a format with zero channels or zero sample rate;installTapwith such a format is undefined behavior in CoreAudio. The adapter now finishes the stream withSensorError.deviceNotFoundup front so the manager falls through to other adapters cleanly instead of crashing. - The
engine.start()failure branch now pairs itscontinuation.finishwithinputNode.removeTap(onBus: 0)so a failed start doesn't leak an installed tap.
- Teardown order in
[1.3.0] - 2026-04-17
Changed
- Swift 6 language mode + complete strict concurrency checking — PR #34.
project.ymlnow setsSWIFT_VERSION: "6"andSWIFT_STRICT_CONCURRENCY: complete;Package.swiftdeclaresswiftLanguageModes: [.v6]. The codebase was already clean under-strict-concurrency=complete(the Makefile'slinttarget has been running with that flag for some time), so the bump produced no new compiler diagnostics — but it locks the contract going forward. @unchecked Sendablesurface shrunk from five app-type-level sites to two narrow framework-handle wrappers — PR #34.AccelResourcesis now a realSendable; the unavoidable IOKit handle escape is isolated in a tiny privateIOKitHandlesstruct whose soundness is justified by the phased construction → single-move publish → once-teardown lifecycle enforced byOnceCleanup.ReportContext.Stateis now genuinelySendable: its containedHighPassFilterandLowPassFilterwere converted fromfinal classtostruct: Sendablewithmutating process(_:).HeadphoneConnectionTrackerdrops@unchecked; under Swift 6,OSAllocatedUnfairLock<Bool>in a final-class NSObject subclass synthesizesSendablecorrectly when every field qualifies.LogStore.Statedrops@unchecked; the culprit wasISO8601DateFormatter(still non-Sendable under Swift 6), replaced with aDateFormatterusingyyyy-MM-dd'T'HH:mm:ss.SSSXXXXX+en_US_POSIX+ GMT that produces byte-identical output.HIDRunLoopThread.Stateretains@uncheckedbecauseThreadandCFRunLoophandles cannot be escaped without redesigning the HID thread itself. The rationale comment now cites the concrete invariants (every access throughOSAllocatedUnfairLock<State>, documented thread-safety ofThread.cancel/CFRunLoopStop, phased lifecycle) that make it sound.
HighPassFilter/LowPassFilterare now Sendable structs withmutating process(_:)— PR #34. API note for any out-of-tree callers: these were previously final classes, so callers that held them byletmust now usevar. All in-tree call sites (AccelerometerReader + the test suite) have been updated.
Fixed
SensorManagerdropped terminaladaptersChanged([])snapshot under concurrent cancellation — PR #33.Sources/SensorKit/SensorAdapter.swift's per-adapter task body caughtCancellationErrorand returned immediately, skipping the subsequentactiveTracker.remove(adapter) + adaptersChanged(...)yield at the bottom of the task body. Under concurrent cancellation (two adapters terminating near-simultaneously) the tracker retained a stale active entry and the terminal empty-snapshot the consumer expects was never emitted. Surfaced by a flaky CI run on PR #30 whereSensorManagerTests.testEventsPublishesAdapterLifecyclesawsnapshots.last == ["B"]instead of[]. Cancellation is a normal adapter termination signal, not a failure, so it now falls through to the shared lifecycle emission.
[1.2.0] - 2026-04-16
Added
- MicrophoneAdapter + HeadphoneMotionAdapter lifecycle test coverage — PR #30. New
MicrophoneAdapterLifecycleTestsandHeadphoneMotionAdapterLifecycleTestscover open/close symmetry, repeated open/close cycles, mid-stream cancellation, and typed-error propagation. Hardware-unavailable paths skip gracefully viaXCTSkipso the suite stays green on CI without real microphones or motion- capable headphones. Total test count 129 → 137. - Framework-list drift guard — PR #29.
make lint-frameworksdiffs the framework list acrossMakefile,Package.swift, andproject.ymland fails on divergence. Wired intomake lintso CI catches any future drift. Caught real drift on first run:IOKitwas missing from the Makefile'sFRAMEWORKSvariable, andUserNotificationswas missing from both the Makefile andproject.yml. All three sources are now aligned on the same eight frameworks.
Changed
MenuBarView.swiftsplit into per-section files — PR #28. The 948-line composition root with 14 nested view structs now lives as a 199-lineMenuBarView.swift(composition root +HeaderSection+ a few shared helpers) plus 12 files underSources/YameteApp/Views/MenuBar/. Extracted views flipped fromprivate→internal; the publicMenuBarViewAPI is unchanged. No behavior change, no renames.- Version extraction via
yqinstead ofsed— PR #29. The Makefile and the release workflow now pullMARKETING_VERSION/CURRENT_PROJECT_VERSIONout ofproject.ymlwithyq '.settings.base.…' project.ymlinstead of a regex, removing the brittle dependency on a specific indent or quoting style. The Makefile errors out with an install hint ifyqis missing; the release workflow installs it viabrew install yqbefore the version check runs. SWIFT_ACTIVE_COMPILATION_CONDITIONSquoting normalized inproject.yml. Debug, DebugDirect, and ReleaseDirect all now use double-quoted string values consistently (previously two were quoted and one was bare).- CI test job routed through
make test— PR #31..github/workflows/ci.ymlnow invokesmake testinstead ofswift test, keeping local and CI invocations in sync. StatusBarController.showPanel()dropsDispatchQueue.main.async— PR #31. The class is already@MainActorso the dispatch hop was redundant. Replaced withTask { MainActor in await Task.yield(); … }, preserving the intentional deferral (letting SwiftUI re-layout after the.menuBarPanelDidShownotification) with one fewer scheduling primitive.
Fixed
SettingsStore.resetToDefaults()force-cast crash risk and duplicate assignments — PR #27. The method had 37as!force-casts pulling values out of an[String: Any]defaults dict, and assignedaccelBandpassLowHzandaccelBandpassHighHztwice. Rewritten to assign every field directly from the typedDefaults.*constants inSources/YameteCore/Defaults.swift— noAnyround-trip, no duplicate assignments, no crash path if the defaults dict is ever mistyped. All 24 SettingsStore tests remain green.
Documentation
project.ymlgains a YAML comment block aboveSWIFT_INCLUDE_PATHS/HEADER_SEARCH_PATHSexplaining why both are required for theIOHIDPublicbridging module (the Swift driver locatesmodule.modulemap, the C compiler resolves the headers it references — removing either breaks the bridging module).project.ymlgains a comment above theYamete-AppStore:scheme noting that itsrunconfig isDebug, so sandbox entitlements only apply viamake appstoreor archive, not via Xcode's Run button. A matching comment lives above the Makefile'sappstore:target.
[1.1.1] - 2026-04-16
Fixed
- Recursive lock crash on launch-at-login — PR #23.
AccelerometerReader.surfaceStall()andhandleReport()calledAsyncThrowingStream.Continuation.finish()/yield()while holding theOSAllocatedUnfairLock.AsyncThrowingStream._Storageacquires its own internalos_unfair_lockin those methods, causing a non-reentrant double-acquisition (abort cause 89859). Fixed by capturing the continuation reference (and the value to yield) as the lock's return value, releasing the lock, then calling the continuation method outside.
[1.1.0] - 2026-04-15
Added
- Auto-update via GitHub Releases (Direct build only) — PR #18. The previously stubbed
Updateris now a full update lifecycle: checks GitHub Releases on launch (throttled to every 4 hours), downloads the signed+notarized DMG, installs, and relaunches. The menu bar footer surfaces live update state with contextual action buttons (check / download / install / restart). All update logic is gated behind#if DIRECT_BUILD; the App Store build keeps the stub because App Store Connect handles distribution there.
Changed
- Menu bar shell replaced:
MenuBarExtra→ directly-managedNSStatusItem+NSPanel— PR #20, closes #19. SwiftUI'sMenuBarExtra(.window)was rendering the popover with a blank gap above the content and letting the desktop wallpaper bleed through the background, making text unreadable on light wallpapers. The newStatusBarControllermanages anNSStatusItemdirectly and hosts the SwiftUI content inside anNSPanelbacked by anNSVisualEffectViewwith.menumaterial, sized to the SwiftUI content before display. Icon reactivity is preserved viawithObservationTrackingrather than embeddingNSHostingViewin the status bar button. Escape and outside-click dismiss behavior are unchanged from user perspective.
Fixed
- Menu bar popover background transparency rendered content unreadable — closes #19. See NSPanel migration above.
[1.0.1] - 2026-04-10
Fixed
- Accelerometer not detected on M5 Macs (Direct build) — closes #15.
SPUAccelerometerAdapter.isAvailablewas gating onisSensorActivelyReporting()for all build variants, creating a chicken-and-egg in the Direct build: the UI hid the accel toggle because the sensor wasn't reporting,impacts()was never called, and so the sensor never started reporting. Worked on M4 because the sensor happened to be warm at launch; broke on M5 because macOS apparently doesn't keep the SPU accel warm by default on that silicon. The runtime probe is now gated behind#if !DIRECT_BUILD— Direct checks onlyisSPUDevicePresent()(it has full IOKit write access and can always self-activate viaSensorActivation.activate()at pipeline start), while the App Store build keeps the full probe because the sandbox constraint is real there.
Changed
- Sensor kickstart helper is now a long-lived daemon with a wake watcher.
docs/sensor-kickstart/yamete-sensor-kickstart.swifthas adaemonsubcommand that runskickstart()once on startup, then subscribes to IOKit system power notifications viaIORegisterForSystemPowerand re-runs the kickstart on everykIOMessageSystemHasPoweredOnevent. The shipping LaunchDaemon plist (com.studnicky.yamete.sensor-kickstart.plist) ships withKeepAlive = true+ProcessType = Background+daemonarg, and aThrottleInterval = 10to rate-limit crash-loop respawns. Idle CPU cost is effectively zero (the daemon sits parked inCFRunLoopRunwaiting for notifications). Motivation: on the hardware we have tested the sensor stays live across sleep/wake, but this is defense in depth for hardware or macOS revisions we have not verified — even if the driver cools the sensor during sleep, the daemon's wake handler re-runs the kickstart before the user notices. - Helper renamed from
docs/community/yamete-accel-warmuptodocs/sensor-kickstart/yamete-sensor-kickstart. The old "community" directory name was a vague dumping-ground; the new name reflects what the thing actually does. Rename cascades across the directory, the Swift source, the LaunchDaemon plist label (com.studnicky.yamete.sensor-kickstart), the binary install path (/usr/local/libexec/yamete-sensor-kickstart), the log path (/var/log/yamete-sensor-kickstart.log), the Swift function (warmup()→kickstart()), the CLI subcommand (warmup→kickstart), and every prose reference in public docs. - GitHub Pages now serves only public content.
docs/was the publish root but had been used as a dumping-ground for internal planning docs. Removed:APP_STORE_METADATA.md,APP_STORE_RELEASE.md,APP_STORE_REVIEW_CHECKLIST.md,CODEX_REVIEW_PLAN.md. Moved to repo root:ARCHITECTURE.md. Kept indocs/as legitimate public content:index.html,support.html,privacy.html,INSTALLATION.md,sensor-kickstart/. - Published GitHub Pages refreshed with personality. The three HTML pages (
index.html,support.html,privacy.html) were rewritten to match the app's actual voice — confident self-awareness instead of dry marketing speak — while keeping every bit of genuinely useful content and adding cheeky-but-helpful FAQs ("Wait, what is this app?", "Is this a joke?", "Do I actually need this? — No.", "Can I use this as a drum machine?"). The support page's accelerometer FAQ walks users through the sandbox situation and links to the sensor-kickstart helper for opt-in power users. @MainActor→MainActorin all prose, commit messages, CHANGELOG, and release bodies. Source code.swiftfiles intentionally keep the@MainActorattribute — it is a compiler-enforced Swift language construct and GitHub's @-mention parser does not index source code blobs. Prose was scrubbed because GitHub was resolving@MainActorin commit bodies to an unrelated GitHub user whose login happens to collide with the Swift concurrency attribute. Twogit filter-repopasses + force-push on master/develop + v1.0.0 re-tag cleaned the history; this release is clean by construction.
Added
- CLAUDE.md + scratch-doc patterns now in
.gitignore. Project- specific Claude Code instructions live in a gitignoredCLAUDE.mdat the repo root with the project-specific hard rules (no@MainActorin prose, no author email anywhere, build system quick reference, branch protection convention, helper internals). Ad-hoc scratch docs (*.scratch.md,*.tmp.md,.plans/,.dev/,scratch/,plans/, etc.) are all gitignored so they never leak into the repo. - IOKit system power message constants (
MsgCanSystemSleep,MsgSystemWillSleep,MsgSystemHasPoweredOn) are defined numerically in the helper because Swift's C importer cannot translate theiokit_common_msg(X)macro expansion. Values are0xe0000000 | XperIOKit/IOMessage.hand are stable across every macOS release since IOKit was introduced. - CI workflow now bumps
actions/checkoutfromv4tov5to stay off the Node.js 20 deprecation treadmill.
Removed
- Email contact (
support@studnicky.com) removed from every published page. GitHub Issues is now the only support channel, and every page that would otherwise say "email X" now says "file an issue at github.com/Studnicky/yamete/issues" with the same friendliness.docs/privacy.htmlContact section points atissues/newinstead of amailto:link, with a one-liner explaining that because the app collects nothing and sends nothing over the network, there's nothing to ask about privately — open issues make better documentation anyway.
[1.0.0] - 2026-04-10
Critical
App Store accelerometer: runtime availability probe replaces the unconditional passive-read assumption. The prior "passive HID read always works because macOS warms the SPU sensor at boot" hypothesis was falsified by a cold-boot verification on a clean Mac with Yamete Direct uninstalled:
ioreg -rxc AppleSPUHIDDrivershowed the accel service (dispatchAccel = Yes, BMI286) withReportInterval = 0x0, noHIDEventServicePropertiesdict, andDebugState._num_events = 0. Two consecutive App Store launches (BTM auto + manualopen) both producedWatchdog staleness=5.0s sampleCount=0— zero samples received, zero events in the driver. Conclusion: macOS does NOT independently warm the SPU accelerometer at cold boot; in prior observations the sensor was warm only because Yamete Direct (which can write to IORegistry from outside the sandbox) had run earlier in the session and left the sensor active.Fix:
SPUAccelerometerAdapter.isAvailablenow does a runtime probe viaAccelHardware.isSensorActivelyReporting()that readsDebugState._last_event_timestampon theAppleSPUHIDDriverservice and compares againstmach_absolute_time(). The adapter reports available only when the sensor has emitted a report within the last 500ms (AccelHardwareConstants.sensorActivityStalenessNs). With this probe,Migration.reconcileSensors— which already runs on every launch and prunes unavailable adapters — correctly drops the accelerometer from the pipeline when the sensor is cold, letting the microphone + headphone-motion fallback path activate cleanly instead of letting the 5s watchdog fire on an empty stream while the user sees no impacts at all.Driver internals discovered while building the probe:
IORegistryEntrySetCFPropertywithReportInterval,SensorPropertyReportingState,SensorPropertyPowerStateis a command channel, not a stored value. The driver'ssetPropertyaccepts the write, triggers the hardware, and returnsKERN_SUCCESSwithout updating the IOKit property dict — so property read-back on the service always returns0regardless of whether the sensor is streaming.DebugState._num_eventsis a monotonic counter that freezes at deactivation and doesn't reset until reboot — useless as a "currently active" signal on its own.DebugState._last_event_timestamp(inmach_absolute_timeunits) is the only field that decays correctly when the sensor goes cold.- Sandbox rejection of
IORegistryEntrySetCFPropertyhappens before the driver'ssetPropertyis reached — the call returnsKERN_SUCCESSto the client but the write never lands. This is why the App Store build cannot activate the sensor itself and depends on an external kickstart path.
External sensor kickstart for App Store users: A minimal Swift helper (
docs/sensor-kickstart/yamete-sensor-kickstart.swift) + LaunchDaemon plist is provided via support docs. Users who want the accelerometer in the App Store build compile it once withswiftc, install the LaunchDaemon to/Library/LaunchDaemons/, and reboot. The helper does the same threeIORegistryEntrySetCFPropertywrites that the Direct build'sSensorActivation.activate()does, and since it runs outside App Sandbox its writes reach the driver. The sensor stays active across subscriber cycles and sleep/wake, and the daemon re-runs the kickstart on every wake as defense in depth. Verified empirically: after kickstart, the App Store build's probe returns true andadapters=["Accelerometer", ...]logs alongsidesampleCount=entries at sustained 100Hz.Sleep/wake verified (2026-04-10): The BMI286 is in Apple Silicon's always-on power domain. Verified empirically that once kickstarted, the sensor continues streaming at 100Hz across sleep/wake cycles without interruption — a 35-second sleep period advanced
_num_eventsfrom 101 to 3615 (100.4 events/sec, exactly the awake rate), meaning the driver was emitting reports the entire time the lid was closed.Still to verify on other Mac models and macOS revisions:
- Multiple Apple Silicon models. All testing so far is on a single development MacBook. Should be re-verified on M1 / M2 / M3 / M4 across MacBook Air / MacBook Pro before submission to confirm
AppleSPUHIDDriver/dispatchAccel/DebugStateare present and behave identically on each generation. - macOS revisions.
IORegistryEntrySetCFPropertyonAppleSPUHIDDriverwith these specific property keys is an undocumented Apple-internal surface. A future macOS update could break it. Re-test before every App Store submission, and monitor reports via the support-docs helper link.
Accelerometer stream watchdog:
SPUAccelerometerAdapternow spawns a backgroundTaskper stream that pollsReportContext.lastReportAtevery 1 second. If no reports arrive for 5 seconds, the watchdog callssurfaceStall()which terminates the stream with a recoverableSensorError.ioKitError. The controller's existing fusion path then falls back to microphone + headphone-motion automatically. The watchdog is cancelled in cleanup phase 0 so it cannot race with teardown. Polls do not race withhandleReport—lastReportAtlives inside the sameOSAllocatedUnfairLock<State>block.HID teardown race fix:
HIDRunLoopThread.join()now blocks the cleanup path until the dedicated HID worker thread has fully exited. CF object mutations (IOHIDManagerUnscheduleFromRunLoop,IOHIDManagerClose) are reordered to run only afterjoin(). ThreadSanitizer reproduced a SEGV inCF_IS_OBJCduring cancel-before-first-report; the fix is TSAN-clean.Unaligned
Int32reads in accelerometer decoder: ReplacedwithMemoryRebound(undefined behavior on misaligned offsets 6/10/14) withUnsafeRawPointer.loadUnaligned(fromByteOffset:as:).IOHIDEventSystemClient activation restored: The C bridging header (IOHIDPublic.h) must declare
IOHIDEventSystemClientCreate(full client). UsingIOHIDEventSystemClientCreateSimpleClientsilently fails to activate the SPU accelerometer — the sensor opens but delivers no reports. The full client is required to set ReportInterval on hardware services.
Added
- Dual-build resource architecture:
App/Resources/{locale}.lproj/Moans.stringsships tame App-Store-safe content;App/Resources-Direct/{locale}.lproj/Moans.stringsoverlays spicy content for the notarized direct-download build only. The Makefile rsync overlay step is gated on the directory's existence so the App Store build never sees the spicy strings.appstore-lintMakefile target greps the bundle fordaddyas a leakage gate. - Notification mode (Flash Mode = Notification): posts an impact banner with a tier-appropriate flirty title + reaction body. Runtime locale picker lets users select the notification language independently of system language. 40 locales supported.
NotificationPhraserandom pool loader: parsesMoans.stringsfor a given locale into prefix-grouped[String]arrays at first use, cached withOSAllocatedUnfairLock. Single-source-of-truthresolveLocaleguarantees title and body always come from the same language per notification.- Always-on menu bar face reaction: the menu bar icon swaps to a face image on every detected impact, independent of Flash Mode. Uses a cached face library rebuilt only on dark/light mode changes.
AccelerometerLifecycleStressTests: 25-cycle repeated open/close + 10-cycle cancel-before-first-report stress tests. Skips on hosts without an SPU device.NotificationResponderTests: 8 unit tests forNotificationPhraseresolveLocale fallback, pool selection, and random sampling. Uses an internal_testInjectseam since.lprojresources are bundled by the Makefile, not the SPM test runner.Makefile linttarget:swiftc -typecheck -strict-concurrency=complete -warnings-as-errors. Currently passes clean.Makefile appstore-linttarget: bundle-content gate that fails if any shippedMoans.stringscontains the substringdaddy.
Changed
- Renamed
FlashResponder→VisualResponderto match current responsibilities (overlay AND notification both implement it). - Rewrote
APP_STORE_METADATA.md: age rating 12+, content descriptors reflect the dual-build distinction, App Review notes acknowledge that the accelerometer driver-property surface is undocumented and offer to remove the path entirely if Review prefers. - Rewrote
APP_STORE_RELEASE.mdBLOCKER-1: removed the "Risk: Low" framing; now explicitly distinguishes public IOKit symbols (compliant) from undocumented driver behavior (gray area under 2.5.1). screenFlashis now a computed proxy overvisualResponseMode.Key.screenFlashremoved fromSettingsStore.Key; the persistedscreenFlashUserDefault is consumed once at init for legacy migration (screenFlash == falseforcesvisualResponseMode = .off), then deleted.ImpactController.shouldBeEnabledreadsvisualResponseMode != .offdirectly instead ofscreenFlash.-strict-concurrency=completecleanup:FaceRenderer.PaletteisSendable;currentPaletteandloadFaces(palette:)areMainActor;Fmtslider formatters are@Sendableclosures;arrayToggleBindingisMainActorand requiresT: Sendable;GateRowsisMainActor;Toggle.themeMiniSwitchisMainActor;OnceCleanuprequiresT: Sendable;LogStore.Stateis@unchecked Sendable(lock-protected).- Removed the
import YameteAppself-import fromYameteApp.swift(was a Makefile-only no-op warning that became an error under-warnings-as-errors).
Removed
FaceRenderer.composeIcon: was used to render a 1024×1024 dock-icon variant of the face. Yamete isLSUIElement(no Dock icon at all), so the compose path was dead code.- All
NSApp.applicationIconImageswap code: same reason — no Dock. - All
moan_*andtitle_*keys fromApp/Resources/*.lproj/Localizable.strings. These now live inMoans.strings(tame) and the Direct overlay (spicy). - Editorialized translator comments ("DDLG kink register", etc.) from the App-Store-bound
Localizable.stringsfiles. Spicy comments survive only inApp/Resources-Direct/, never shipped to the Store.
Fixed
- Visual Response = Off no longer animates dock/menu bar in disallowed ways: dock did not exist; menu bar always reacts (now documented). Setting renamed UI label to "Flash Mode".
- Notification copy mismatch: hint text no longer claims notifications "clear when the cooldown ends" — it now correctly says the entry is removed from Notification Center, with banner visibility controlled by macOS.
notification_titleshowing as a literal key:LocalizedStringshelper falls back to the main bundle (which goes throughCFBundleDevelopmentRegion) when a key is missing in the requested lproj. Title and body now both resolve correctly across locales.- Menu bar icon scaling during reaction: SVG NSImage is resized to 18pt logical size before storing in
reactionFace. The menu bar honors intrinsic NSImage size, not SwiftUI.frame(), inside MenuBarExtra. - Site, privacy, and support pages aligned with the dual-build reality and the actual (non-existent) Dock surface.
Added
- ImpactTier enum (Tap/Light/Medium/Firm/Hard) with tier display in menu footer
- DetectionConfig struct for atomic configuration of detection parameters
- AudioResponder and VisualResponder protocols for dependency injection
- SensorID type-safe identifier newtype
- NSScreen.displayID extension replacing duplicated NSDeviceDescriptionKey usage
- AccordionCard and SettingHeader reusable UI components
- Per-setting SF Symbol icons with tappable inline help
- Collapsible Device Settings and Sensitivity Settings panels
- Crest factor gate with background RMS tracking (1-second EMA)
- Privacy policy (PRIVACY.md) and PrivacyInfo.xcprivacy manifest
- App Sandbox with device.usb entitlement for Mac App Store
- All user-facing strings wrapped in NSLocalizedString
Changed
- Accelerometer: IOHIDEventSystemClient activation + IOHIDManager reading (public IOKit API)
- Detection: 4-algorithm voting → 6-gate pipeline (bandpass, spike, rise rate, crest factor, confirmations, rearm)
- Sensitivity renamed to Reactivity with inverted mapping (higher = more reactive)
- Assets loaded from sounds/ and faces/ folders recursively by extension
- Sound selection: pre-sorted by duration at startup, intensity maps to clip length
- ImpactController split into detect() → respond() with DetectedImpact struct
- Debounce merged with rearm into single Cooldown control
- SensorFusionEngine renamed to ImpactFusionEngine
- Frequency band: configurable bandpass (HP 20Hz + LP 25Hz default)
- All detection parameters exposed as user-configurable advanced settings
- Entitlements consolidated to single file (Yamete.entitlements)
- Makefile: hardened runtime, proper process kill cycle, release target cleanup
- Config push to detection engine cached (only on change, not per sample)
- HID thread init: DispatchSemaphore replaces spinlock
- LogStore graceful fallback when Application Support unavailable
Fixed
- MainActor isolation on ImpactFusionEngine (was unconfined)
- ScreenFlash hide Task missing MainActor (AppKit thread safety)
- Updater Tasks missing MainActor (state mutation isolation)
- EventContext use-after-free on stream termination
- Force unwraps in AccelerometerReader run loop and mode resolution
- Rise rate gate checking instantaneous value instead of window peak
- Settings schema reset wiping all user preferences on every default change
Removed
- ImpactDetector, SignalDetectors, DetectorConfig (replaced by ImpactFusionEngine)
- Self-updater (updates via Mac App Store)
- Licensing infrastructure (LicenseManager, LicenseStore, trial period)
- Schema version reset mechanism (UserDefaults.register handles new keys)
- Yamete-hardened.entitlements (consolidated into Yamete.entitlements)
- Prefix-based asset naming requirement (sound_, face_)
- Unused SliderRow view component and AudioDeviceUID type
0.0.0 - 2026-04-02
Added
- Impact detection via BMI286 accelerometer on Apple Silicon Macs
- Menu bar UI with branded pink theme
- Audio and visual response to impacts
- Dual-sink logging with 24-hour retention
- Build via Makefile with swiftc
- MIT license