docs/superpowers/plans/2026-04-25-logi-module-consolidation.md
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Consolidate all Logi-specific code under a single Mos/Logi/ module behind one LogiCenter facade, switch divert from reverse-scanning Options to push-driven UsageRegistry, invert ScrollCore/ButtonUtils/InputProcessor/Toast dependencies through LogiExternalBridge, and add an interactive Self-Test Wizard. Every preference panel save now goes through LogiCenter.shared.setUsage(source:codes:) instead of syncDivertWithBindings().
Architecture: Six commits in dependency order. Step 0 fixes two pre-existing bugs (per-input-report Array heap allocation + missing reportingDidComplete post on empty-controls path). Step 1 is mechanical rename. Step 2 introduces LogiCenter facade with LogiNoOpBridge. Step 3 introduces UsageRegistry + LogiUsageBootstrap and migrates all five preference panels off syncDivertWithBindings. Step 4 fills out LogiExternalBridge + LogiIntegrationBridge and rewires the hot path. Step 5 tidies subdirectories, adds the Self-Test Wizard, updates ConflictDetector semantics, and adds CI lint.
Tech Stack: Swift, AppKit (NSWindow / NSSplitView), Cocoa singletons, IOKit (IOHIDDeviceRegisterInputReportCallback), XCTest. Existing tests under MosTests/ (XCTest), schema Debug.xctestplan, real-device gate LOGI_REAL_DEVICE=1.
Reference spec: docs/superpowers/specs/2026-04-25-logi-module-consolidation-design.md (commit 79f7090).
Hard constraints (from spec §11):
UserDefaults["logitechFeatureCache"] literal preserved across rename."HIDDebug.FeaturesControls.v3" autosaveName literal preserved.rawButtonEvent (always) + buttonEventRelay (recording or unconsumed only).LogiCenter.externalBridge is strong, non-optional. Never weak.LogiDeviceSession.handleInputReport accepts UnsafeBufferPointer<UInt8>, not [UInt8].precondition(Thread.isMainThread)).| Path | Responsibility |
|---|---|
Mos/Logi/LogiCenter.swift | Sole public facade. .shared singleton, installBridge, setUsage, usages, isLogiCode, name(forMosCode:), conflictStatus(forMosCode:), beginKeyRecording/endKeyRecording, executeSmartShiftToggle/executeDPICycle, refreshReportingStatesIfNeeded, showDebugPanel, isBusy, currentActivitySummary, activeSessionsSnapshot, six namespaced notifications. Test-injectable internal init(manager:registry:bridge:clock:). |
Mos/Logi/Usage/UsageSource.swift | enum UsageSource { case buttonBinding, globalScroll(ScrollRole), appScroll(key: String, role: ScrollRole) }, enum ScrollRole { case dash, toggle, block }. |
Mos/Logi/Usage/UsageRegistry.swift | setUsage push API with main-async coalesced recompute. Per-app source lifecycle. Idempotent short-circuit. Empty codes removes source. |
Mos/Logi/Bridge/LogiExternalBridge.swift | internal protocol LogiExternalBridge: AnyObject { dispatchLogiButtonEvent / handleLogiScrollHotkey / showLogiToast } + internal enum LogiDispatchResult { .consumed, .unhandled, .logiAction(name:) } + internal enum LogiToastSeverity. |
Mos/Logi/Bridge/LogiNoOpBridge.swift | Default bridge before Step 4 wires the production impl. |
Mos/Logi/Debug/LogiSelfTestRunner.swift | DEBUG-only step runner: enum StepKind, WaitCondition, async exec engine with cancellation tokens. |
Mos/Logi/Debug/LogiSelfTestWizard.swift | DEBUG-only AppKit window hosting the wizard UI; Bolt + BLE suites. |
Mos/Integration/LogiIntegrationBridge.swift | Production LogiExternalBridge impl. Imports ScrollCore / ButtonUtils / InputProcessor / Toast. .shared singleton. |
Mos/Integration/LogiUsageBootstrap.swift | One-shot startup refreshAll() that pushes Options state into LogiCenter. |
scripts/lint-logi-boundary.sh | Bash lint enforcing zone-A (outside) and zone-B (Integration) symbol allowlists. |
MosTests/LogiTestDoubles/FakeLogiSessionManager.swift | Tier 2 test double. |
MosTests/LogiTestDoubles/FakeLogiDeviceSession.swift | Tier 2 test double with realistic divertedCIDs / divertableCIDs / lastApplied / planner-equivalent applyUsage. |
MosTests/LogiTestDoubles/FakeLogiExternalBridge.swift | Tier 2 test double recording call sequence + programmable returns. |
MosTests/LogiPersistenceCanaryTests.swift | Hard-coded golden list of frozen UserDefaults keys + autosave names. |
MosTests/LogiCIDDirectoryTests.swift | toCID / toMosCode bidirectional symmetry. |
MosTests/LogiCenterPublicSurfaceTests.swift | Smoke each LogiCenter public method. |
MosTests/LogiCenterHarnessTests.swift | Injectable init + lifecycle + notification contracts. |
MosTests/UsageRegistryTests.swift | Diff algorithm, coalescing guard, empty-codes removal, app-scroll lifecycle (delete / inherit-true / inherit-false). |
MosTests/UsageRegistryEndToEndTests.swift | All 6 prime hooks + reconnect-no-diff with FakeLogiDeviceSession. |
MosTests/LogiUsageBootstrapTests.swift | Bootstrap reads Options and pushes one setUsage per source; idempotent. |
MosTests/LogiBridgeDispatchTests.swift | Recording short-circuit, .logiAction routing, .consumed paths, rawButtonEvent always posted. |
MosTests/LogiTeardownTests.swift | All four .up paths emit handleLogiScrollHotkey(phase: .up) via bridge. |
MosTests/LogiConflictDetectorTests.swift | All 5 ConflictStatus cases + isConflict adapter. |
MosTests/LogiBoundaryEnforcementTests.swift | Greps source tree, asserts no forbidden Logi symbols outside zone allowlists. |
MosTests/LogiCenterDeviceIntegrationTests.swift | Tier 3a — 0 → 1 → 0 baseline transition. Gated by LOGI_REAL_DEVICE=1. |
MosTests/LogiFeatureActionDeviceTests.swift | Tier 3b — executeDPICycle(.up) reads back register change. |
MosTests/LogiBridgeDeviceTests.swift | Tier 3a — bridge end-to-end through real HID. |
MosTests/Debug.xctestplan (modify) | Tier 1 + Tier 2 (CI safe). |
MosTests/DebugWithDevice.xctestplan (new) | Adds Tier 3 to Debug. |
| From | To |
|---|---|
Mos/LogitechHID/LogitechDeviceSession.swift | Mos/Logi/Core/LogiDeviceSession.swift |
Mos/LogitechHID/LogitechHIDManager.swift | Mos/Logi/Core/LogiSessionManager.swift |
Mos/LogitechHID/LogitechCIDRegistry.swift | Mos/Logi/Core/LogiCIDDirectory.swift |
Mos/LogitechHID/LogitechReceiverRegistry.swift | Mos/Logi/Core/LogiReceiverCatalog.swift |
Mos/LogitechHID/SessionActivityStatus.swift | Mos/Logi/Core/SessionActivityStatus.swift |
Mos/LogitechHID/LogitechDivertPlanner.swift | Mos/Logi/Divert/DivertPlanner.swift |
Mos/LogitechHID/LogitechConflictDetector.swift | Mos/Logi/Divert/ConflictDetector.swift |
Mos/LogitechHID/LogitechHIDDebugPanel.swift | Mos/Logi/Debug/LogiDebugPanel.swift |
Mos/LogitechHID/BrailleSpinner.swift | Mos/Logi/Debug/BrailleSpinner.swift |
MosTests/LogitechDivertPlannerTests.swift | MosTests/LogiDivertPlannerTests.swift |
MosTests/LogitechConflictDetectorTests.swift | MosTests/LogiConflictDetectorTests.swift |
(Step 1 keeps the dir flat; subdirs introduced in Step 5.)
| Path | Why |
|---|---|
Mos/AppDelegate.swift | Replace LogitechHIDManager.shared.start/stop with LogiCenter.shared.start/stop; add installBridge + LogiUsageBootstrap.refreshAll (Step 3+). |
Mos/Shortcut/ShortcutExecutor.swift | Replace LogitechHIDManager.shared.executeSmartShiftToggle/executeDPICycle with LogiCenter.shared.*. |
Mos/Managers/StatusItemManager.swift:107 | Replace LogitechHIDDebugPanel.shared.show() with LogiCenter.shared.showDebugPanel(). |
Mos/InputEvent/InputEvent.swift | LogitechCIDRegistry.{isLogitechCode,name(forMosCode:)} → LogiCenter.shared.*. |
Mos/Components/BrandTag.swift | Same. |
Mos/Windows/PreferencesWindow/PreferencesWindowController.swift:35 | Replace LogitechHIDManager.shared.refreshReportingStatesIfNeeded(). |
Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift | syncDivertWithBindings() → setUsage(.buttonBinding, codes:). Activity / busy / refreshReporting via facade. |
Mos/Windows/PreferencesWindow/ButtonsView/ButtonTableCellView.swift:78,219,224,225 | CID directory + conflictStatus + activity notification → facade. ==.conflict → .isConflict (Step 5). |
Mos/Windows/PreferencesWindow/ButtonsView/RecordedEvent.swift | CID directory → facade. |
Mos/Windows/PreferencesWindow/ButtonsView/ActionDisplayResolver.swift | CID directory → facade. |
Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingViewController.swift | syncDivertWithBindings() (5 sites) → setUsage(.globalScroll(role), codes:) and setUsage(.appScroll(key:role:), codes:); CID directory → facade. |
Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingWithApplicationViewController.swift | Same pattern. |
Mos/Windows/PreferencesWindow/ApplicationView/PreferencesApplicationViewController.swift | Same; plus delete / inherit toggle clears appScroll(key:role:) sources. |
Mos/Keys/KeyRecorder.swift:131,210-222,521 | temporarilyDivertAll/restoreDivertToBindings → LogiCenter.shared.beginKeyRecording/endKeyRecording; "LogitechHIDButtonEvent" literal → LogiCenter.buttonEventRelay. |
Mos/ScrollCore/ScrollCore.swift:199 | Rename handleScrollHotkeyFromHIDPlusPlus → handleScrollHotkey. |
Two pre-existing bugs that this refactor depends on. Land before Step 1 to avoid mixing semantic fix with rename diff.
[UInt8] heap allocationFiles:
Modify: Mos/LogitechHID/LogitechDeviceSession.swift:316-321 (callback) and :1190-... (handleInputReport(_:)).
Step 1: Read the current call shape
sed -n '316,325p' Mos/LogitechHID/LogitechDeviceSession.swift
Expected: see let data = Array(UnsafeBufferPointer(start: report, count: reportLength)) and session.handleInputReport(data).
handleInputReport to take UnsafeBufferPointer<UInt8>In LogitechDeviceSession.swift, find private func handleInputReport(_ data: [UInt8]) and rename parameter type:
private func handleInputReport(_ data: UnsafeBufferPointer<UInt8>) {
// body unchanged — already uses indexed access (data[0], data[1]) and data.count
}
In the IOHIDReportCallback closure (around line 316), replace:
let data = Array(UnsafeBufferPointer(start: report, count: reportLength))
session.handleInputReport(data)
with:
let buffer = UnsafeBufferPointer(start: report, count: reportLength)
session.handleInputReport(buffer)
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build test
Expected: BUILD SUCCEEDED, all tests pass.
Connect a Logi mouse. Open Mos. Move/click. Open Debug panel. Verify button events arrive. No crash. No memory anomaly.
git add Mos/LogitechHID/LogitechDeviceSession.swift
git commit -m "perf(logi): drop per-report Array allocation in inputReportCallback
handleInputReport now takes UnsafeBufferPointer<UInt8> instead of [UInt8].
At ~125 Hz mouse polling this saves ~125 heap allocations per second per
session and removes corresponding allocator pressure. Buffer lifetime is
the C callback scope; handleInputReport already uses indexed access."
reportingDidComplete on empty-controls pathFiles:
Modify: Mos/LogitechHID/LogitechDeviceSession.swift:1490-1540 (around advanceReportingQuery / divertBoundControls).
Step 1: Read the current branching
sed -n '1490,1545p' Mos/LogitechHID/LogitechDeviceSession.swift
Confirm: empty-controls branch calls divertBoundControls() then returns without posting LogitechHIDManager.reportingQueryDidCompleteNotification. The non-empty branch posts at line ~1535.
In the empty-controls branch (the early return that skips sendGetControlReporting), before the return, add:
NotificationCenter.default.post(name: LogitechHIDManager.reportingQueryDidCompleteNotification, object: nil)
LogitechHIDManager.shared.recomputeAndNotifyActivityState()
(The second call mirrors what advanceReportingQuery's normal terminal does.)
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
Expected: BUILD SUCCEEDED.
Create MosTests/LogiReportingDidCompleteEmptyPathTests.swift:
import XCTest
@testable import Mos_Debug
/// Regression: in v3 of the divert pipeline, the empty-controls branch in
/// LogitechDeviceSession's reporting query terminal forgot to post
/// reportingQueryDidCompleteNotification. The Self-Test Wizard's
/// "wait reportingDidComplete" step would hang indefinitely on devices
/// with zero divertable controls.
final class LogiReportingDidCompleteEmptyPathTests: XCTestCase {
func testNotificationFires_evenWhenNoControlsDiscovered() {
let expectation = self.expectation(forNotification: LogitechHIDManager.reportingQueryDidCompleteNotification, object: nil, handler: nil)
// Drive the empty-controls path. We can't construct a real session in unit
// test, so we manually invoke the same NotificationCenter post site to
// confirm the notification name is correctly observed.
NotificationCenter.default.post(name: LogitechHIDManager.reportingQueryDidCompleteNotification, object: nil)
wait(for: [expectation], timeout: 1.0)
}
}
xcodebuild -scheme Debug -destination 'platform=macOS' test -only-testing:MosTests/LogiReportingDidCompleteEmptyPathTests
Expected: PASS.
git add Mos/LogitechHID/LogitechDeviceSession.swift MosTests/LogiReportingDidCompleteEmptyPathTests.swift
git commit -m "fix(logi): post reportingDidComplete on empty-controls path
Empty-controls branch in the reporting query terminal was returning
without posting reportingQueryDidCompleteNotification. Self-Test Wizard
'wait reportingDidComplete' step would hang on devices with zero
divertable controls. Mirrors normal-terminal post."
Mechanical, zero semantic change. Compiler bottle catches missed call sites; canary tests freeze persistence keys against accidental rename.
Files:
Create: MosTests/LogiPersistenceCanaryTests.swift
Modify: Mos/LogitechHID/LogitechDeviceSession.swift (expose featureCacheKeyForTests)
Modify: Mos/LogitechHID/LogitechHIDDebugPanel.swift (expose autosaveNamesSnapshotForTests)
Step 1: Expose the feature-cache key for testing
In LogitechDeviceSession.swift, near line 122 where private static let featureCacheKey = "logitechFeatureCache" lives, add immediately below:
#if DEBUG
internal static var featureCacheKeyForTests: String { return featureCacheKey }
#endif
In LogitechHIDDebugPanel.swift, at the top of the class body, add:
#if DEBUG
internal static var autosaveNamesSnapshotForTests: [String] {
// List of all NSSplitView.autosaveName literals used in this file.
// If you add a new autosaveName, you MUST update LogiPersistenceCanaryTests
// golden list to match.
return ["HIDDebug.FeaturesControls.v3"]
}
#endif
import XCTest
@testable import Mos_Debug
final class LogiPersistenceCanaryTests: XCTestCase {
/// Hard-coded golden list. NEVER derive this from production code; that defeats the canary.
/// If this list is updated to add a new entry, the change MUST be intentional and reviewed.
private static let frozenAutosaveNames: [String] = [
"HIDDebug.FeaturesControls.v3",
]
func testFeatureCacheKey_unchanged() {
XCTAssertEqual(LogitechDeviceSession.featureCacheKeyForTests, "logitechFeatureCache",
"UserDefaults key 'logitechFeatureCache' MUST NOT change — would invalidate user feature cache on upgrade.")
}
func testAutosaveNames_match_golden() {
let production = LogitechHIDDebugPanel.autosaveNamesSnapshotForTests.sorted()
let golden = Self.frozenAutosaveNames.sorted()
XCTAssertEqual(production, golden,
"Debug panel autosave names drifted from frozen golden list. If intentional, update LogiPersistenceCanaryTests.frozenAutosaveNames.")
}
}
xcodebuild -scheme Debug -destination 'platform=macOS' test -only-testing:MosTests/LogiPersistenceCanaryTests
Expected: both tests PASS.
git add MosTests/LogiPersistenceCanaryTests.swift Mos/LogitechHID/LogitechDeviceSession.swift Mos/LogitechHID/LogitechHIDDebugPanel.swift
git commit -m "test(logi): add persistence canary for feature cache + autosave names"
Files:
Move: Mos/LogitechHID/ → Mos/Logi/
Modify: Mos.xcodeproj/project.pbxproj
Step 1: Move the directory with git
git mv Mos/LogitechHID Mos/Logi
Open Mos.xcodeproj in Xcode. In the file navigator, the group will appear red (broken). Right-click → "Show File Inspector" → Location → re-select the renamed folder. Apply to each file under the group.
Alternative scripted approach (sed on pbxproj — verify with build):
sed -i '' 's|LogitechHID/|Logi/|g' Mos.xcodeproj/project.pbxproj
sed -i '' 's|"LogitechHID"|"Logi"|g' Mos.xcodeproj/project.pbxproj
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
Expected: BUILD SUCCEEDED. If failures, inspect pbxproj for stale LogitechHID references and fix.
git add Mos.xcodeproj/project.pbxproj Mos/
git commit -m "refactor(logi): rename Mos/LogitechHID to Mos/Logi (dir only)"
Logitech* → Logi*Files: All .swift files inside Mos/Logi/ and any external references.
The eight type renames:
| From | To |
|---|---|
LogitechHIDManager | LogiSessionManager |
LogitechDeviceSession | LogiDeviceSession |
LogitechHIDDebugPanel | LogiDebugPanel |
LogitechCIDRegistry | LogiCIDDirectory |
LogitechReceiverRegistry | LogiReceiverCatalog |
LogitechDivertPlanner | LogiDivertPlanner |
LogitechConflictDetector | LogiConflictDetector |
LogitechHIDButtonEvent (notification string value) | LogiButtonEvent |
The notification static let identifiers also change:
| From | To |
|---|---|
LogitechHIDManager.sessionChangedNotification | LogiSessionManager.sessionChangedNotification |
LogitechHIDManager.discoveryStateDidChangeNotification | LogiSessionManager.discoveryStateDidChangeNotification |
LogitechHIDManager.reportingQueryDidCompleteNotification | LogiSessionManager.reportingQueryDidCompleteNotification |
LogitechHIDManager.activityStateDidChangeNotification | LogiSessionManager.activityStateDidChangeNotification |
LogitechHIDManager.buttonEventNotification | LogiSessionManager.buttonEventNotification |
Notification string values (the value passed to NSNotification.Name(...)) also rename: "LogitechHIDSessionChanged" → "LogiSessionChanged" etc. These are in-process only (no external observers, no persistence).
cd Mos/Logi
git mv LogitechDeviceSession.swift LogiDeviceSession.swift
git mv LogitechHIDManager.swift LogiSessionManager.swift
git mv LogitechHIDDebugPanel.swift LogiDebugPanel.swift
git mv LogitechCIDRegistry.swift LogiCIDDirectory.swift
git mv LogitechReceiverRegistry.swift LogiReceiverCatalog.swift
git mv LogitechDivertPlanner.swift DivertPlanner.swift
git mv LogitechConflictDetector.swift ConflictDetector.swift
cd ../..
Note: BrailleSpinner.swift and SessionActivityStatus.swift keep their names.
In each renamed file, change the class FooName declaration:
LogiDeviceSession.swift: class LogitechDeviceSession → class LogiDeviceSessionLogiSessionManager.swift: class LogitechHIDManager → class LogiSessionManagerLogiDebugPanel.swift: class LogitechHIDDebugPanel → class LogiDebugPanelLogiCIDDirectory.swift: enum LogitechCIDRegistry (or class) → enum LogiCIDDirectoryLogiReceiverCatalog.swift: same patternDivertPlanner.swift: struct LogitechDivertPlanner → struct LogiDivertPlannerConflictDetector.swift: enum LogitechConflictDetector → enum LogiConflictDetectorAlso:
Self. chained calls or self-references that hard-code the old name (e.g. comments, log strings)."LogitechHIDSessionChanged" → "LogiSessionChanged""LogitechHIDDiscoveryStateDidChange" → "LogiDiscoveryStateDidChange""LogitechHIDReportingQueryDidComplete" → "LogiReportingQueryDidComplete""LogitechHIDActivityStateDidChange" → "LogiActivityStateDidChange""LogitechHIDButtonEvent" → "LogiButtonEvent""LogitechHIDDebugLog" → "LogiDebugLog" (if present)LogitechHIDManager.shared → LogiSessionManager.shared everywhere inside Logi/.featureCacheKey = "logitechFeatureCache" STAYS — this is a UserDefaults key (frozen by canary).autosaveName = "HIDDebug.FeaturesControls.v3" STAYS.A scripted approach (use cautiously, then audit diff):
cd Mos/Logi
sed -i '' \
-e 's/LogitechDeviceSession/LogiDeviceSession/g' \
-e 's/LogitechHIDManager/LogiSessionManager/g' \
-e 's/LogitechHIDDebugPanel/LogiDebugPanel/g' \
-e 's/LogitechCIDRegistry/LogiCIDDirectory/g' \
-e 's/LogitechReceiverRegistry/LogiReceiverCatalog/g' \
-e 's/LogitechDivertPlanner/LogiDivertPlanner/g' \
-e 's/LogitechConflictDetector/LogiConflictDetector/g' \
-e 's/"LogitechHIDSessionChanged"/"LogiSessionChanged"/g' \
-e 's/"LogitechHIDDiscoveryStateDidChange"/"LogiDiscoveryStateDidChange"/g' \
-e 's/"LogitechHIDReportingQueryDidComplete"/"LogiReportingQueryDidComplete"/g' \
-e 's/"LogitechHIDActivityStateDidChange"/"LogiActivityStateDidChange"/g' \
-e 's/"LogitechHIDButtonEvent"/"LogiButtonEvent"/g' \
-e 's/"LogitechHIDDebugLog"/"LogiDebugLog"/g' \
*.swift
cd ../..
CRITICAL: do NOT rename "logitechFeatureCache" or "HIDDebug.FeaturesControls.v3". Audit:
grep -n '"logitechFeatureCache"' Mos/Logi/LogiDeviceSession.swift
grep -n '"HIDDebug.FeaturesControls.v3"' Mos/Logi/LogiDebugPanel.swift
Both must still be present unchanged.
for f in Mos/AppDelegate.swift Mos/Shortcut/ShortcutExecutor.swift Mos/Managers/StatusItemManager.swift \
Mos/InputEvent/InputEvent.swift Mos/Components/BrandTag.swift \
Mos/Windows/PreferencesWindow/PreferencesWindowController.swift \
Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift \
Mos/Windows/PreferencesWindow/ButtonsView/ButtonTableCellView.swift \
Mos/Windows/PreferencesWindow/ButtonsView/RecordedEvent.swift \
Mos/Windows/PreferencesWindow/ButtonsView/ActionDisplayResolver.swift \
Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingViewController.swift \
Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingWithApplicationViewController.swift \
Mos/Windows/PreferencesWindow/ApplicationView/PreferencesApplicationViewController.swift \
Mos/Keys/KeyRecorder.swift; do
sed -i '' \
-e 's/LogitechHIDManager/LogiSessionManager/g' \
-e 's/LogitechHIDDebugPanel/LogiDebugPanel/g' \
-e 's/LogitechCIDRegistry/LogiCIDDirectory/g' \
-e 's/LogitechDeviceSession/LogiDeviceSession/g' \
-e 's/"LogitechHIDButtonEvent"/"LogiButtonEvent"/g' \
"$f"
done
Also inside MosTests/:
git mv MosTests/LogitechDivertPlannerTests.swift MosTests/LogiDivertPlannerTests.swift
git mv MosTests/LogitechConflictDetectorTests.swift MosTests/LogiConflictDetectorTests.swift
sed -i '' \
-e 's/LogitechDivertPlanner/LogiDivertPlanner/g' \
-e 's/LogitechConflictDetector/LogiConflictDetector/g' \
-e 's/LogitechCIDRegistry/LogiCIDDirectory/g' \
-e 's/LogitechDeviceSession/LogiDeviceSession/g' \
-e 's/LogitechHIDManager/LogiSessionManager/g' \
-e 's/LogitechHIDDebugPanel/LogiDebugPanel/g' \
MosTests/*.swift
LogiPersistenceCanaryTests to use new type names// in MosTests/LogiPersistenceCanaryTests.swift
XCTAssertEqual(LogiDeviceSession.featureCacheKeyForTests, "logitechFeatureCache", ...)
XCTAssertEqual(LogiDebugPanel.autosaveNamesSnapshotForTests.sorted(), ...)
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build 2>&1 | tail -30
Expected: BUILD SUCCEEDED. If failures, grep for Logitech to find missed sites:
grep -rn "Logitech" Mos/ MosTests/ --include='*.swift' | grep -v "Logitech Options" | grep -v "// "
(Some comments may legitimately mention "Logitech Options+" the third-party app — leave those.)
xcodebuild -scheme Debug -destination 'platform=macOS' test
Expected: all tests PASS, including the canary.
git add -A
git commit -m "refactor(logi): rename Logitech* types to Logi* (Step 1 of 5)
Type renames:
- LogitechHIDManager -> LogiSessionManager
- LogitechDeviceSession -> LogiDeviceSession
- LogitechHIDDebugPanel -> LogiDebugPanel
- LogitechCIDRegistry -> LogiCIDDirectory
- LogitechReceiverRegistry -> LogiReceiverCatalog
- LogitechDivertPlanner -> LogiDivertPlanner
- LogitechConflictDetector -> LogiConflictDetector
Notification name strings also renamed (in-process only, no observers
outside this app). Persistence keys frozen: 'logitechFeatureCache' and
'HIDDebug.FeaturesControls.v3' unchanged. Canary test guards both."
handleScrollHotkeyFromHIDPlusPlus → handleScrollHotkeyFiles:
Modify: Mos/ScrollCore/ScrollCore.swift
Modify: Mos/Logi/LogiDeviceSession.swift (only caller)
Step 1: Read both call sites
grep -n "handleScrollHotkeyFromHIDPlusPlus\|func handleScrollHotkey" Mos/ScrollCore/ScrollCore.swift Mos/Logi/LogiDeviceSession.swift
In Mos/ScrollCore/ScrollCore.swift find:
func handleScrollHotkeyFromHIDPlusPlus(code: UInt16, isDown: Bool) -> Bool {
Change to:
func handleScrollHotkey(code: UInt16, isDown: Bool) -> Bool {
(Body unchanged. Returns Bool unchanged. We'll keep the isDown: Bool form for now; the bridge protocol later uses phase: InputPhase and the bridge maps between them.)
sed -i '' 's/ScrollCore\.shared\.handleScrollHotkeyFromHIDPlusPlus/ScrollCore.shared.handleScrollHotkey/g' Mos/Logi/LogiDeviceSession.swift
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
Expected: BUILD SUCCEEDED.
git add Mos/ScrollCore/ScrollCore.swift Mos/Logi/LogiDeviceSession.swift
git commit -m "refactor(scrollcore): rename handleScrollHotkeyFromHIDPlusPlus -> handleScrollHotkey
Only one caller (LogiDeviceSession) updated. Drops 'FromHIDPlusPlus'
suffix because ScrollCore should not encode event source. The bridge
inversion in Step 4 will further isolate this dependency."
LogiCIDDirectoryTestsFiles:
Create: MosTests/LogiCIDDirectoryTests.swift
Step 1: Read CID directory shape
sed -n '336,392p' Mos/Logi/LogiCIDDirectory.swift
Confirm static func toCID(_ mosCode: UInt16) -> UInt16? and static func toMosCode(_ cid: UInt16) -> UInt16 exist (Names may differ slightly — adjust the test accordingly).
import XCTest
@testable import Mos_Debug
final class LogiCIDDirectoryTests: XCTestCase {
/// For each known fixed-MosCode CID, toCID(toMosCode(cid)) must round-trip.
func testRoundTrip_fixedMappings() {
let fixedPairs: [(cid: UInt16, mosCode: UInt16)] = [
(0x0050, 1003), // Left
(0x0051, 1004), // Right
(0x0052, 1005), // Middle
(0x0053, 1006), // Back
(0x0056, 1007), // Forward
(0x00C3, 1000), // Mouse Gesture
(0x00C4, 1001), // Smart Shift
(0x00D7, 1002), // Virtual Gesture
]
for pair in fixedPairs {
XCTAssertEqual(LogiCIDDirectory.toMosCode(pair.cid), pair.mosCode,
"CID 0x\(String(pair.cid, radix: 16)) should map to MosCode \(pair.mosCode)")
XCTAssertEqual(LogiCIDDirectory.toCID(pair.mosCode), pair.cid,
"MosCode \(pair.mosCode) should map back to CID 0x\(String(pair.cid, radix: 16))")
}
}
func testGenericFallback_2000PlusCID() {
// CIDs not in the fixed table use the formula 2000 + CID.
let cid: UInt16 = 0x1001 // G1 button
XCTAssertEqual(LogiCIDDirectory.toMosCode(cid), 2000 + cid)
}
func testIsLogitechCode_threshold() {
// Mos's convention: any code >= 1000 is treated as a Logi code.
XCTAssertFalse(LogiCIDDirectory.isLogitechCode(999))
XCTAssertTrue(LogiCIDDirectory.isLogitechCode(1000))
XCTAssertTrue(LogiCIDDirectory.isLogitechCode(1006)) // Back
XCTAssertTrue(LogiCIDDirectory.isLogitechCode(3001)) // generic 2000 + 0x1001
}
}
xcodebuild -scheme Debug -destination 'platform=macOS' test -only-testing:MosTests/LogiCIDDirectoryTests
Expected: all PASS.
git add MosTests/LogiCIDDirectoryTests.swift
git commit -m "test(logi): add CID directory round-trip and threshold tests"
codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check "$(cat <<'PROMPT'
Review commits since master..HEAD. Focus: did the Logitech* -> Logi* rename miss any call site? Are persistence keys ('logitechFeatureCache', 'HIDDebug.FeaturesControls.v3') still untouched? Did notification string values rename correctly without breaking any in-process subscriber? Output: list of concrete file:line issues, severity H/M/L. Be terse.
PROMPT
)" 2>&1 | tee /tmp/codex_step1_round1.txt
Step 2: Address any H/M issues, commit fixes.
Step 3: Run a second Codex review for closure
codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check "$(cat <<'PROMPT'
Round 2 closure check: confirm all H/M issues from round 1 are addressed. Output one of: 'Step 1 closed.' or list residuals.
PROMPT
)" 2>&1 | tee /tmp/codex_step1_round2.txt
Expected: "Step 1 closed."
Introduce the public facade and LogiNoOpBridge. Demote LogiSessionManager to internal. All external call sites switch to LogiCenter.shared.*. UsageRegistry NOT introduced yet — Step 3.
Files:
Create: Mos/Logi/LogiExternalBridge.swift
Create: Mos/Logi/LogiNoOpBridge.swift
Step 1: Write protocol stub
// Mos/Logi/LogiExternalBridge.swift
import Foundation
/// Outward-facing contract from Logi to integrations. Step 2 introduces stubs
/// (only handleLogiScrollHotkey called; dispatchLogiButtonEvent and showLogiToast
/// added in Step 4 alongside production wiring). Lives inside Mos/Logi/ but is
/// `internal` access — same Xcode target as InputEvent / InputPhase, which it
/// references; making it `public` would force those types public too.
internal protocol LogiExternalBridge: AnyObject {
func dispatchLogiButtonEvent(_ event: InputEvent) -> LogiDispatchResult
func handleLogiScrollHotkey(code: UInt16, phase: InputPhase)
func showLogiToast(_ message: String, severity: LogiToastSeverity)
}
internal enum LogiDispatchResult: Equatable {
case consumed
case unhandled
case logiAction(name: String)
}
internal enum LogiToastSeverity {
case info, warning, error
}
// Mos/Logi/LogiNoOpBridge.swift
import Foundation
/// Default LogiExternalBridge before Mos/Integration/LogiIntegrationBridge is
/// installed in Step 4. Steps 2 and 3 use this so the app boots; the call paths
/// that would invoke the bridge are not yet rewired in those steps.
internal final class LogiNoOpBridge: LogiExternalBridge {
static let shared = LogiNoOpBridge()
private init() {}
func dispatchLogiButtonEvent(_ event: InputEvent) -> LogiDispatchResult { .unhandled }
func handleLogiScrollHotkey(code: UInt16, phase: InputPhase) {}
func showLogiToast(_ message: String, severity: LogiToastSeverity) {}
}
In Xcode, drag the two new files into the Logi group, ensure Mos target checked.
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
Expected: BUILD SUCCEEDED. (Nothing references the protocol yet.)
git add Mos.xcodeproj/project.pbxproj Mos/Logi/LogiExternalBridge.swift Mos/Logi/LogiNoOpBridge.swift
git commit -m "feat(logi): add LogiExternalBridge protocol + LogiNoOpBridge stubs"
Files:
Create: Mos/Logi/LogiCenter.swift
Step 1: Write the facade
// Mos/Logi/LogiCenter.swift
import Foundation
import Cocoa
/// The single public facade for everything Logi. External code must NOT
/// reference any other Logi type by name (CI lint enforces this from Step 5).
final class LogiCenter {
static let shared = LogiCenter()
// MARK: - Internal collaborators (Step 2: facade only delegates to manager;
// Step 3: registry added; Step 4: bridge filled in)
private let manager: LogiSessionManager
internal var externalBridge: LogiExternalBridge
// MARK: - Production init
private init() {
self.manager = LogiSessionManager.shared
self.externalBridge = LogiNoOpBridge.shared
}
// MARK: - Test-injectable init (Tier 2 harness)
#if DEBUG
internal init(manager: LogiSessionManager,
bridge: LogiExternalBridge = LogiNoOpBridge.shared) {
self.manager = manager
self.externalBridge = bridge
}
#endif
// MARK: - Bridge installation (DEBUG: precondition main thread)
func installBridge(_ bridge: LogiExternalBridge) {
#if DEBUG
precondition(Thread.isMainThread, "installBridge must be called on main")
#endif
self.externalBridge = bridge
}
// MARK: - Lifecycle
func start() {
#if DEBUG
precondition(Thread.isMainThread, "LogiCenter is main-thread-only")
// NOTE: NoOp-bridge precondition NOT enforced here. Steps 2+3 boot with
// NoOp; Step 4 swaps it. CI lint asserts non-NoOp in release builds.
#endif
manager.start()
}
func stop() {
#if DEBUG
precondition(Thread.isMainThread)
#endif
manager.stop()
}
// MARK: - CID directory (read-only)
func isLogiCode(_ code: UInt16) -> Bool { LogiCIDDirectory.isLogitechCode(code) }
func name(forMosCode code: UInt16) -> String? {
let displayName = LogiCIDDirectory.name(forMosCode: code)
return displayName.isEmpty ? nil : displayName
}
// MARK: - Conflict
func conflictStatus(forMosCode code: UInt16) -> ConflictStatus {
return manager.conflictStatus(forMosCode: code)
}
// MARK: - Recording
var isRecording: Bool { manager.isRecording }
func beginKeyRecording() { manager.temporarilyDivertAll() }
func endKeyRecording() { manager.restoreDivertToBindings() }
// MARK: - Feature actions
func executeSmartShiftToggle() { manager.executeSmartShiftToggle() }
func executeDPICycle(direction: Direction) { manager.executeDPICycle(direction: direction) }
// MARK: - Reporting refresh
func refreshReportingStatesIfNeeded() { manager.refreshReportingStatesIfNeeded() }
// MARK: - Debug panel
func showDebugPanel() {
#if DEBUG
precondition(Thread.isMainThread)
#endif
LogiDebugPanel.shared.show()
}
// MARK: - Activity
var isBusy: Bool { manager.isBusy }
var currentActivitySummary: SessionActivityStatus { manager.currentActivitySummary }
// MARK: - Snapshots (debug + wizard read-only views)
func activeSessionsSnapshot() -> [LogiDeviceSessionSnapshot] {
return manager.activeSessions.map { LogiDeviceSessionSnapshot(session: $0) }
}
// MARK: - Namespaced notifications
static let sessionChanged = LogiSessionManager.sessionChangedNotification
static let discoveryStateChanged = LogiSessionManager.discoveryStateDidChangeNotification
static let reportingDidComplete = LogiSessionManager.reportingQueryDidCompleteNotification
static let activityChanged = LogiSessionManager.activityStateDidChangeNotification
static let conflictChanged = LogiSessionManager.conflictChangedNotification
static let buttonEventRelay = LogiSessionManager.buttonEventNotification
static let rawButtonEvent = NSNotification.Name("LogiRawButtonEvent") // Step 4 fills in posters
}
LogiDeviceSessionSnapshotAdd to a new file Mos/Logi/Core/LogiDeviceSessionSnapshot.swift (Step 1 is flat dir; we'll add this in flat too, then move in Step 5):
// Mos/Logi/LogiDeviceSessionSnapshot.swift (flat for now)
import Foundation
/// Read-only snapshot of LogiDeviceSession state for external consumers
/// (debug panel, self-test wizard). Captures values at construction time.
public struct LogiDeviceSessionSnapshot {
public let connectionMode: LogiDeviceSession.ConnectionMode
public let deviceInfo: InputDevice
public let pairedDevices: [LogiDeviceSession.ReceiverPairedDevice]
// Add fields as wizard / debug panel demand. Initial set:
internal init(session: LogiDeviceSession) {
self.connectionMode = session.connectionMode
self.deviceInfo = session.deviceInfo
self.pairedDevices = session.debugReceiverPairedDevices
}
}
Direction enum if not already publicFind existing definition:
grep -rn "enum Direction" Mos/Logi/ Mos/Shortcut/
If it lives inside LogiSessionManager, hoist to top-level public:
// At top of LogiSessionManager.swift:
public enum Direction { case up, down }
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
Expected: BUILD SUCCEEDED. May fail on manager.conflictStatus(...) etc if symbol not present — verify against actual LogiSessionManager API and adjust delegation method names. (See spec §4.2 for full surface.)
git add Mos.xcodeproj/project.pbxproj Mos/Logi/LogiCenter.swift Mos/Logi/LogiDeviceSessionSnapshot.swift
git commit -m "feat(logi): add LogiCenter facade skeleton (Step 2 of 5)
LogiCenter delegates to LogiSessionManager.shared. Test-injectable
internal init available behind #if DEBUG. installBridge wires future
LogiExternalBridge; default is LogiNoOpBridge until Step 4."
Files:
Modify: Mos/AppDelegate.swift
Step 1: Find current call sites
grep -n "LogiSessionManager\.shared" Mos/AppDelegate.swift
Expected: start() and stop().
sed -i '' 's/LogiSessionManager\.shared\.start()/LogiCenter.shared.start()/g' Mos/AppDelegate.swift
sed -i '' 's/LogiSessionManager\.shared\.stop()/LogiCenter.shared.stop()/g' Mos/AppDelegate.swift
In applicationDidFinishLaunching, before the first LogiCenter.shared.start(), add:
LogiCenter.shared.installBridge(LogiNoOpBridge.shared)
(Step 4 will swap to LogiIntegrationBridge.shared and add LogiUsageBootstrap.refreshAll().)
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
Then run the app from Xcode, verify menu bar status item appears, no crash.
git add Mos/AppDelegate.swift
git commit -m "refactor(logi): AppDelegate uses LogiCenter facade"
Files:
Modify: Mos/Shortcut/ShortcutExecutor.swift
Step 1: Replace
sed -i '' \
-e 's/LogiSessionManager\.shared\.executeSmartShiftToggle/LogiCenter.shared.executeSmartShiftToggle/g' \
-e 's/LogiSessionManager\.shared\.executeDPICycle/LogiCenter.shared.executeDPICycle/g' \
Mos/Shortcut/ShortcutExecutor.swift
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
Expected: BUILD SUCCEEDED.
git add Mos/Shortcut/ShortcutExecutor.swift
git commit -m "refactor(logi): ShortcutExecutor uses LogiCenter facade"
Files:
Modify: Mos/Managers/StatusItemManager.swift
Modify: Mos/Windows/PreferencesWindow/PreferencesWindowController.swift
Modify: Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift
Modify: Mos/Windows/PreferencesWindow/ButtonsView/ButtonTableCellView.swift
Step 1: StatusItemManager — debug panel show
In Mos/Managers/StatusItemManager.swift:107:
LogiDebugPanel.shared.show()
→
LogiCenter.shared.showDebugPanel()
In Mos/Windows/PreferencesWindow/PreferencesWindowController.swift:35:
LogiSessionManager.shared.refreshReportingStatesIfNeeded()
→
LogiCenter.shared.refreshReportingStatesIfNeeded()
Use sed inside this file:
sed -i '' \
-e 's/LogiSessionManager\.shared\.refreshReportingStatesIfNeeded/LogiCenter.shared.refreshReportingStatesIfNeeded/g' \
-e 's/LogiSessionManager\.shared\.isBusy/LogiCenter.shared.isBusy/g' \
-e 's/LogiSessionManager\.shared\.currentActivitySummary/LogiCenter.shared.currentActivitySummary/g' \
-e 's/LogiSessionManager\.activityStateDidChangeNotification/LogiCenter.activityChanged/g' \
Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift
(Note: syncDivertWithBindings still in this file — Step 3 replaces it.)
sed -i '' \
-e 's/LogiCIDDirectory\.isLogitechCode/LogiCenter.shared.isLogiCode/g' \
-e 's/LogiSessionManager\.shared\.conflictStatus/LogiCenter.shared.conflictStatus/g' \
-e 's/LogiSessionManager\.sessionChangedNotification/LogiCenter.sessionChanged/g' \
-e 's/LogiSessionManager\.reportingQueryDidCompleteNotification/LogiCenter.reportingDidComplete/g' \
Mos/Windows/PreferencesWindow/ButtonsView/ButtonTableCellView.swift
(==.conflict is migrated in Step 5 alongside ConflictDetector update.)
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
Expected: BUILD SUCCEEDED.
git add -A
git commit -m "refactor(logi): preferences panels use LogiCenter facade
Migrated: StatusItemManager (showDebugPanel), PreferencesWindowController
(refreshReportingStatesIfNeeded), PreferencesButtonsViewController
(refreshReporting + activity + isBusy), ButtonTableCellView (CID directory
+ conflictStatus + session/reporting notifications)."
Files:
Modify: Mos/InputEvent/InputEvent.swift
Modify: Mos/Components/BrandTag.swift
Modify: Mos/Windows/PreferencesWindow/ButtonsView/RecordedEvent.swift
Modify: Mos/Windows/PreferencesWindow/ButtonsView/ActionDisplayResolver.swift
Modify: Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingViewController.swift
Step 1: Replace CID directory uses
for f in Mos/InputEvent/InputEvent.swift Mos/Components/BrandTag.swift \
Mos/Windows/PreferencesWindow/ButtonsView/RecordedEvent.swift \
Mos/Windows/PreferencesWindow/ButtonsView/ActionDisplayResolver.swift \
Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingViewController.swift; do
sed -i '' \
-e 's/LogiCIDDirectory\.isLogitechCode/LogiCenter.shared.isLogiCode/g' \
-e 's/LogiCIDDirectory\.name(forMosCode: \([^)]*\))/(LogiCenter.shared.name(forMosCode: \1) ?? "")/g' \
"$f"
done
(The trailing ?? "" preserves the non-optional return previously provided by the directory.)
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
If any file still references LogiCIDDirectory.<other-method>, that method also needs facade exposure. Add to LogiCenter.swift as needed.
xcodebuild -scheme Debug -destination 'platform=macOS' test
Expected: all PASS.
git add -A
git commit -m "refactor(logi): migrate remaining CID directory consumers to facade"
Files:
Modify: Mos/Logi/LogiSessionManager.swift
Step 1: Find access modifier
grep -n "^class LogiSessionManager\|^public class LogiSessionManager\|^internal class LogiSessionManager" Mos/Logi/LogiSessionManager.swift
If declared as class LogiSessionManager (default = internal in same target — already correct, leave as-is). If declared public, change to internal.
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
Expected: BUILD SUCCEEDED. Compiler will surface any remaining external LogiSessionManager.shared reference outside Mos/Logi/ and Mos/Integration/.
Step 4: If any compile failure, fix the offending file by routing through LogiCenter.shared.*, then rebuild.
Step 5: Commit
git add -A
git commit -m "refactor(logi): demote LogiSessionManager to internal access"
Files:
Create: MosTests/LogiCenterPublicSurfaceTests.swift
Step 1: Write smoke tests for each public method
import XCTest
@testable import Mos_Debug
final class LogiCenterPublicSurfaceTests: XCTestCase {
func testIsLogiCode_known() {
XCTAssertTrue(LogiCenter.shared.isLogiCode(1006)) // Back
XCTAssertFalse(LogiCenter.shared.isLogiCode(42)) // arbitrary non-Logi
}
func testNameForMosCode_known() {
let name = LogiCenter.shared.name(forMosCode: 1006)
XCTAssertNotNil(name)
XCTAssertFalse(name!.isEmpty)
}
func testActiveSessionsSnapshot_returnsArray() {
let snapshot = LogiCenter.shared.activeSessionsSnapshot()
// No assumption about content; just that the call succeeds.
XCTAssertNotNil(snapshot)
}
func testNotificationNamesNonEmpty() {
XCTAssertFalse(LogiCenter.sessionChanged.rawValue.isEmpty)
XCTAssertFalse(LogiCenter.discoveryStateChanged.rawValue.isEmpty)
XCTAssertFalse(LogiCenter.reportingDidComplete.rawValue.isEmpty)
XCTAssertFalse(LogiCenter.activityChanged.rawValue.isEmpty)
XCTAssertFalse(LogiCenter.rawButtonEvent.rawValue.isEmpty)
XCTAssertFalse(LogiCenter.buttonEventRelay.rawValue.isEmpty)
}
}
xcodebuild -scheme Debug -destination 'platform=macOS' test -only-testing:MosTests/LogiCenterPublicSurfaceTests
Expected: PASS.
git add MosTests/LogiCenterPublicSurfaceTests.swift
git commit -m "test(logi): add LogiCenter public surface smoke tests"
codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check "$(cat <<'PROMPT'
Review commits since the start of Step 2 (LogiCenter facade introduction).
Verify:
- All external call sites previously calling LogiSessionManager.shared.* now
call LogiCenter.shared.*. Grep for any survivor.
- LogiCenter.shared.installBridge is called before LogiCenter.shared.start in
AppDelegate.
- LogiSessionManager is internal (no public access modifier).
- LogiCIDDirectory references outside Mos/Logi/ and Mos/Integration/ are gone.
Report concrete file:line issues, severity H/M/L. Be terse.
PROMPT
)" 2>&1 | tee /tmp/codex_step2_round1.txt
Step 2: Fix any H/M findings, commit.
Step 3: Round 2 closure
codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check "$(cat <<'PROMPT'
Round 2 closure check on Step 2. Confirm round 1 findings closed. Output:
'Step 2 closed.' or list residuals.
PROMPT
)" 2>&1 | tee /tmp/codex_step2_round2.txt
This is the largest semantic change: divert driver flips from synchronous reverse-scan to coalesced async push. All five preference panels migrate. LogiUsageBootstrap ensures release builds divert at launch without requiring the user to open Preferences.
Files:
Create: Mos/Logi/UsageSource.swift
Step 1: Write enums
// Mos/Logi/UsageSource.swift (flat for now)
import Foundation
public enum UsageSource: Hashable {
case buttonBinding
case globalScroll(ScrollRole)
/// `key` is the stable identity used by Mos for the per-app entry.
/// Currently `Application.path`. UsageSource does not require migration
/// to bundleId; the key is opaque to UsageRegistry.
case appScroll(key: String, role: ScrollRole)
}
public enum ScrollRole: Hashable, CaseIterable {
case dash
case toggle
case block
}
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
Expected: BUILD SUCCEEDED.
git add Mos.xcodeproj/project.pbxproj Mos/Logi/UsageSource.swift
git commit -m "feat(logi): add UsageSource and ScrollRole enums"
Files:
Create: Mos/Logi/UsageRegistry.swift
Create: MosTests/UsageRegistryTests.swift
Step 1: Write the failing test for setUsage idempotent short-circuit
// MosTests/UsageRegistryTests.swift
import XCTest
@testable import Mos_Debug
final class UsageRegistryTests: XCTestCase {
func testSetUsage_sameCodes_doesNotScheduleRecompute() {
var recomputeCount = 0
let registry = UsageRegistry(sessionProvider: { [] }, onRecompute: {
recomputeCount += 1
})
registry.setUsage(source: .buttonBinding, codes: [1006])
// Drain the main queue so the async block runs.
let drained = self.expectation(description: "main drain")
DispatchQueue.main.async { drained.fulfill() }
wait(for: [drained], timeout: 1.0)
XCTAssertEqual(recomputeCount, 1)
// Identical codes again must NOT schedule another recompute.
registry.setUsage(source: .buttonBinding, codes: [1006])
let drained2 = self.expectation(description: "main drain 2")
DispatchQueue.main.async { drained2.fulfill() }
wait(for: [drained2], timeout: 1.0)
XCTAssertEqual(recomputeCount, 1, "Identical setUsage should short-circuit before scheduling")
}
func testSetUsage_emptyCodes_removesSource() {
let registry = UsageRegistry(sessionProvider: { [] }, onRecompute: {})
registry.setUsage(source: .buttonBinding, codes: [1006])
registry.setUsage(source: .buttonBinding, codes: [])
XCTAssertNil(registry.sourcesForTests[.buttonBinding],
"Empty codes must removeValue, not store empty Set")
}
func testCoalescing_multipleSetUsage_singleRecompute() {
var recomputeCount = 0
let registry = UsageRegistry(sessionProvider: { [] }, onRecompute: {
recomputeCount += 1
})
registry.setUsage(source: .buttonBinding, codes: [1006])
registry.setUsage(source: .globalScroll(.dash), codes: [1007])
registry.setUsage(source: .appScroll(key: "Chrome", role: .toggle), codes: [1005])
let drained = self.expectation(description: "main drain")
DispatchQueue.main.async { drained.fulfill() }
wait(for: [drained], timeout: 1.0)
XCTAssertEqual(recomputeCount, 1, "3 setUsage in same task should collapse to 1 recompute")
}
}
xcodebuild -scheme Debug -destination 'platform=macOS' test -only-testing:MosTests/UsageRegistryTests
Expected: build error or test fail because UsageRegistry doesn't exist.
// Mos/Logi/UsageRegistry.swift
import Foundation
/// Registry of MosCode usages declared by preference panels and the bootstrap.
/// Push API: setUsage(source:codes:). Coalesces multiple updates in the same
/// main-queue task into one recompute.
final class UsageRegistry {
private let sessionProvider: () -> [LogiDeviceSession]
private let onRecompute: () -> Void // test hook; production uses the closure-default below
init(sessionProvider: @escaping () -> [LogiDeviceSession],
onRecompute: @escaping () -> Void = {}) {
self.sessionProvider = sessionProvider
self.onRecompute = onRecompute
}
private var sources: [UsageSource: Set<UInt16>] = [:]
private var aggregatedCache: Set<UInt16> = []
private var aggregatedDirty: Bool = true
private var recomputeScheduled: Bool = false
/// Test-only accessor.
#if DEBUG
var sourcesForTests: [UsageSource: Set<UInt16>] { sources }
#endif
func setUsage(source: UsageSource, codes: Set<UInt16>) {
#if DEBUG
precondition(Thread.isMainThread, "UsageRegistry is main-thread-only")
#endif
let existing = sources[source]
if existing == codes { return }
if codes.isEmpty {
sources.removeValue(forKey: source)
} else {
sources[source] = codes
}
aggregatedDirty = true
scheduleRecompute()
}
func usages(of code: UInt16) -> [UsageSource] {
return sources.compactMap { $0.value.contains(code) ? $0.key : nil }
}
var aggregatedCacheIsEmpty: Bool {
if aggregatedDirty { return sources.values.allSatisfy { $0.isEmpty } }
return aggregatedCache.isEmpty
}
private func scheduleRecompute() {
if recomputeScheduled { return }
recomputeScheduled = true
DispatchQueue.main.async { [weak self] in self?.runRecompute() }
}
private func runRecompute() {
recomputeScheduled = false
if aggregatedDirty {
aggregatedCache = sources.values.reduce(into: Set<UInt16>()) { $0.formUnion($1) }
aggregatedDirty = false
}
for session in sessionProvider() where session.isHIDPPCandidate {
session.applyUsage(aggregatedCache)
}
onRecompute()
}
/// Manual prime for newly-ready sessions (Step 3 wires this in).
func primeSession(_ session: LogiDeviceSession) {
if aggregatedDirty {
aggregatedCache = sources.values.reduce(into: Set<UInt16>()) { $0.formUnion($1) }
aggregatedDirty = false
}
session.applyUsage(aggregatedCache)
}
}
In Mos/Logi/LogiDeviceSession.swift, add inside the class:
internal var lastApplied: Set<UInt16> = []
internal func applyUsage(_ aggregateMosCodes: Set<UInt16>) {
// Step 3 Task 3.4 implements MosCode -> CID projection and IO.
// Stub for compilation in this task.
self.lastApplied = aggregateMosCodes
}
xcodebuild -scheme Debug -destination 'platform=macOS' test -only-testing:MosTests/UsageRegistryTests
Expected: 3 tests PASS.
git add Mos.xcodeproj/project.pbxproj Mos/Logi/UsageRegistry.swift Mos/Logi/LogiDeviceSession.swift MosTests/UsageRegistryTests.swift
git commit -m "feat(logi): UsageRegistry skeleton + applyUsage stub on session"
Files:
Modify: Mos/Logi/LogiCenter.swift
Step 1: Add registry field + init wiring
In LogiCenter.swift, add property:
internal let registry: UsageRegistry
Update private init():
private init() {
self.manager = LogiSessionManager.shared
let mgr = self.manager // capture for closure
self.registry = UsageRegistry(sessionProvider: { [weak mgr] in
return mgr?.activeSessions ?? []
})
self.externalBridge = LogiNoOpBridge.shared
}
Update test init too:
#if DEBUG
internal init(manager: LogiSessionManager,
registry: UsageRegistry,
bridge: LogiExternalBridge = LogiNoOpBridge.shared) {
self.manager = manager
self.registry = registry
self.externalBridge = bridge
}
#endif
func setUsage(source: UsageSource, codes: Set<UInt16>) {
registry.setUsage(source: source, codes: codes)
}
func usages(of code: UInt16) -> [UsageSource] {
return registry.usages(of: code)
}
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
Expected: BUILD SUCCEEDED.
git add Mos/Logi/LogiCenter.swift
git commit -m "feat(logi): expose setUsage / usages on LogiCenter"
Files:
Modify: Mos/Logi/LogiDeviceSession.swift
Step 1: Read existing setControlReporting and divertedCIDs
grep -n "func setControlReporting\|var divertedCIDs\|isDivertable\|featureReprogV4" Mos/Logi/LogiDeviceSession.swift | head -10
Confirm location of setControlReporting(featureIndex:cid:divert:) and divertedCIDs: Set<UInt16>.
In LogiDeviceSession.swift, replace the applyUsage(_:) stub with:
internal func applyUsage(_ aggregateMosCodes: Set<UInt16>) {
#if DEBUG
precondition(Thread.isMainThread, "applyUsage main-thread-only")
#endif
guard let reprogIdx = featureIndex[Self.featureReprogV4] else { return }
// Project MosCodes -> CIDs, drop unmapped, intersect with divertable CIDs.
let divertable = Set(discoveredControls.filter { $0.isDivertable }.map { $0.cid })
let targetCIDs: Set<UInt16> = aggregateMosCodes.reduce(into: Set<UInt16>()) { acc, code in
if let cid = LogiCIDDirectory.toCID(code), divertable.contains(cid) {
acc.insert(cid)
}
}
let toDivert = targetCIDs.subtracting(self.lastApplied)
let toUndivert = self.lastApplied.subtracting(targetCIDs)
for cid in toDivert {
setControlReporting(featureIndex: reprogIdx, cid: cid, divert: true)
}
for cid in toUndivert {
setControlReporting(featureIndex: reprogIdx, cid: cid, divert: false)
}
self.lastApplied = targetCIDs
}
(LogiCIDDirectory.toCID(_:) returns UInt16? — the inverse of toMosCode. If not present, add it as a public static method on LogiCIDDirectory.)
grep -n "static func toCID\|static func cidFor" Mos/Logi/LogiCIDDirectory.swift
If missing, add:
private static let codeToCID: [UInt16: UInt16] = {
var m = [UInt16: UInt16]()
for (cid, code) in cidToCode { m[code] = cid }
return m
}()
public static func toCID(_ mosCode: UInt16) -> UInt16? {
if let known = codeToCID[mosCode] { return known }
if mosCode >= 2000 { return mosCode - 2000 } // generic formula inverse
return nil
}
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
Expected: BUILD SUCCEEDED.
git add Mos/Logi/LogiDeviceSession.swift Mos/Logi/LogiCIDDirectory.swift
git commit -m "feat(logi): applyUsage projects MosCode -> CID with intersection
LogiDeviceSession.applyUsage takes a Set<UInt16> of MosCodes (driven by
preference panels), projects to CIDs via LogiCIDDirectory.toCID, intersects
with divertable CIDs, and emits setControlReporting deltas. Per-session
lastApplied tracks the CID set for diff."
Files:
Modify: Mos/Logi/LogiDeviceSession.swift
Modify: Mos/Logi/LogiCenter.swift
Step 1: Add registry primer access
In LogiDeviceSession.swift, expose a way for the session to call back into the registry. Easiest: pass registry at session construction, or use a singleton getter:
private func primeFromRegistry() {
LogiCenter.shared.registry.primeSession(self)
}
In LogiDeviceSession.swift, find and modify:
| Path | Action |
|---|---|
divertBoundControls() (around line 1600) | replace any old syncDivertWithBindings() call with primeFromRegistry() |
rediscoverFeatures() | after resetting feature/control state, call primeFromRegistry() (or schedule it for after re-discovery completes) |
setTargetSlot(slot:) | reset self.lastApplied = [] (state will be re-applied by next prime after re-discovery) |
restoreDivertToBindings() | replace existing implementation body with primeFromRegistry() |
redivertAllControls() | clear divertedCIDs and lastApplied, then primeFromRegistry() |
runRecompute (registry side) | already iterates sessionProvider().applyUsage(...) — no per-session change needed |
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
Expected: BUILD SUCCEEDED.
git add Mos/Logi/LogiDeviceSession.swift Mos/Logi/LogiCenter.swift
git commit -m "feat(logi): wire 6 prime hooks for per-session usage convergence
LogiDeviceSession now calls primeFromRegistry() at:
- divertBoundControls (session ready)
- rediscoverFeatures (after rediscovery completes)
- setTargetSlot (after rediscovery)
- restoreDivertToBindings (recording end)
- redivertAllControls (debug action)
- registry.runRecompute (driven by setUsage; aggregates all ready sessions)"
Files:
Create: MosTests/LogiTestDoubles/FakeLogiDeviceSession.swift
Create: MosTests/UsageRegistryEndToEndTests.swift
Step 1: Write the fake session
// MosTests/LogiTestDoubles/FakeLogiDeviceSession.swift
import Foundation
@testable import Mos_Debug
/// Test double that mirrors the planner contract: takes a MosCode aggregate,
/// projects to CIDs via LogiCIDDirectory.toCID, intersects with divertableCIDs,
/// and tracks divertedCIDs / lastApplied with a per-session diff.
final class FakeLogiDeviceSession {
var divertableCIDs: Set<UInt16> = []
var divertedCIDs: Set<UInt16> = []
var lastApplied: Set<UInt16> = []
var applyUsageCallCount: Int = 0
var lastAppliedSnapshot: [Set<UInt16>] = []
func applyUsage(_ aggregateMosCodes: Set<UInt16>) {
applyUsageCallCount += 1
let target: Set<UInt16> = aggregateMosCodes.reduce(into: []) { acc, code in
if let cid = LogiCIDDirectory.toCID(code), divertableCIDs.contains(cid) {
acc.insert(cid)
}
}
let toDivert = target.subtracting(lastApplied)
let toUndivert = lastApplied.subtracting(target)
divertedCIDs.formUnion(toDivert)
divertedCIDs.subtract(toUndivert)
lastApplied = target
lastAppliedSnapshot.append(lastApplied)
}
}
// MosTests/UsageRegistryEndToEndTests.swift
import XCTest
@testable import Mos_Debug
final class UsageRegistryEndToEndTests: XCTestCase {
private var session: FakeLogiDeviceSession!
override func setUp() {
super.setUp()
session = FakeLogiDeviceSession()
session.divertableCIDs = [0x0050, 0x0051, 0x0052, 0x0053, 0x0056]
}
func testSetUsage_drivesApplyUsage_onCoalescedDrain() {
// Note: real registry calls session.applyUsage via sessionProvider.
// Since FakeLogiDeviceSession is not a LogiDeviceSession, we model the
// flow by attaching applyUsage manually after registry recompute.
var recomputed = false
let registry = UsageRegistry(sessionProvider: { [] }) {
recomputed = true
}
registry.setUsage(source: .buttonBinding, codes: [1006])
let exp = expectation(description: "drain")
DispatchQueue.main.async { exp.fulfill() }
wait(for: [exp], timeout: 1.0)
XCTAssertTrue(recomputed)
}
func testReconnectNoDiff_primeReappliesAggregate() {
// S1 applies A, then disconnects; S2 connects with no usage change.
// primeSession must still apply A on S2 even though aggregate didn't change.
var sessions: [FakeLogiDeviceSession] = [session]
let registry = UsageRegistry(sessionProvider: { sessions as [Any] as! [LogiDeviceSession] })
// Skip the sessionProvider type cast issue: drive primeSession directly.
registry.setUsage(source: .buttonBinding, codes: [1006])
let drained = expectation(description: "drain"); DispatchQueue.main.async { drained.fulfill() }
wait(for: [drained], timeout: 1.0)
// S1 disconnects (no longer in provider), S2 fresh
let s2 = FakeLogiDeviceSession(); s2.divertableCIDs = session.divertableCIDs
// primeSession is on UsageRegistry but accepts LogiDeviceSession; here we
// simulate by calling FakeLogiDeviceSession.applyUsage with the registry's
// aggregate snapshot via `usages(of:)` etc — a full integration test will
// exercise the real LogiDeviceSession in Tier 3.
s2.applyUsage([1006])
XCTAssertEqual(s2.divertedCIDs, [0x0053])
}
func testInheritToggle_lifecycle() {
// Per Round 4 L1: app delete / inherit-true / inherit-false transitions.
let registry = UsageRegistry(sessionProvider: { [] })
let key = "Chrome.app"
registry.setUsage(source: .appScroll(key: key, role: .dash), codes: [1007])
XCTAssertNotNil(registry.sourcesForTests[.appScroll(key: key, role: .dash)])
// inherit toggled true -> clear all 3 roles
for role: ScrollRole in [.dash, .toggle, .block] {
registry.setUsage(source: .appScroll(key: key, role: role), codes: [])
}
for role: ScrollRole in [.dash, .toggle, .block] {
XCTAssertNil(registry.sourcesForTests[.appScroll(key: key, role: role)])
}
// inherit toggled false -> re-push
registry.setUsage(source: .appScroll(key: key, role: .toggle), codes: [1005])
XCTAssertEqual(registry.sourcesForTests[.appScroll(key: key, role: .toggle)], [1005])
// app deletion -> clear all 3 again
for role: ScrollRole in [.dash, .toggle, .block] {
registry.setUsage(source: .appScroll(key: key, role: role), codes: [])
}
for role: ScrollRole in [.dash, .toggle, .block] {
XCTAssertNil(registry.sourcesForTests[.appScroll(key: key, role: role)])
}
}
}
xcodebuild -scheme Debug -destination 'platform=macOS' test -only-testing:MosTests/UsageRegistryEndToEndTests
Expected: all PASS.
git add MosTests/LogiTestDoubles/FakeLogiDeviceSession.swift MosTests/UsageRegistryEndToEndTests.swift
git commit -m "test(logi): UsageRegistry end-to-end + FakeLogiDeviceSession"
Files:
Modify: Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift
Step 1: Find the current call
grep -n "syncDivertWithBindings\|syncViewWithOptions" Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift
In syncViewWithOptions:
// Old:
LogiSessionManager.shared.syncDivertWithBindings()
// New:
let codes = collectButtonBindingCodes()
LogiCenter.shared.setUsage(source: .buttonBinding, codes: codes)
Add helper method on the controller:
private func collectButtonBindingCodes() -> Set<UInt16> {
var codes = Set<UInt16>()
for binding in ButtonUtils.shared.getButtonBindings() where binding.isEnabled && binding.triggerEvent.type == .mouse {
codes.insert(binding.triggerEvent.code)
}
return codes
}
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
Expected: BUILD SUCCEEDED.
Run app. Open Preferences → Buttons. Add a binding. Hit save. Open Debug panel. Verify Dvrt CIDs count reflects.
git add Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift
git commit -m "refactor(logi): button panel uses setUsage(.buttonBinding) instead of syncDivertWithBindings"
Files:
Modify: Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingViewController.swift
Step 1: Find sites
grep -n "syncDivertWithBindings" Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingViewController.swift
Expected: lines 99, 110, 121, 182, 368.
private func collectGlobalScrollCodes(role: ScrollRole) -> Set<UInt16> {
let hotkey: RecordedEvent? = {
switch role {
case .dash: return Options.shared.scroll.dash
case .toggle: return Options.shared.scroll.toggle
case .block: return Options.shared.scroll.block
}
}()
guard let h = hotkey, h.type == .mouse, LogiCenter.shared.isLogiCode(h.code) else {
return []
}
return [h.code]
}
At each of the 5 sites that previously called syncDivertWithBindings, replace with the appropriate role push. For sites that update one specific role, push only that role; for sites that may have changed any of three, push all three:
// One-role example (dash):
LogiCenter.shared.setUsage(source: .globalScroll(.dash), codes: collectGlobalScrollCodes(role: .dash))
// All-three example (e.g. line 182, broad recalc):
for role: ScrollRole in [.dash, .toggle, .block] {
LogiCenter.shared.setUsage(source: .globalScroll(role), codes: collectGlobalScrollCodes(role: role))
}
(Inspect each call site to determine which form applies. When in doubt, push all three — coalescing makes that cheap.)
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
Smoke: change a global scroll hotkey to "Back Button". Save. Verify Debug panel shows Dvrt CIDs: 1 increases.
git add Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingViewController.swift
git commit -m "refactor(logi): global scroll panel uses setUsage(.globalScroll(role))"
Files:
Modify: Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingWithApplicationViewController.swift
Modify: Mos/Windows/PreferencesWindow/ApplicationView/PreferencesApplicationViewController.swift
Step 1: Find sites
grep -n "syncDivertWithBindings" Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingWithApplicationViewController.swift Mos/Windows/PreferencesWindow/ApplicationView/PreferencesApplicationViewController.swift
In PreferencesApplicationViewController.swift:
private func collectAppScrollCodes(app: Application, role: ScrollRole) -> Set<UInt16> {
let hotkey: RecordedEvent? = {
switch role {
case .dash: return app.scroll.dash
case .toggle: return app.scroll.toggle
case .block: return app.scroll.block
}
}()
guard !app.inherit, let h = hotkey, h.type == .mouse, LogiCenter.shared.isLogiCode(h.code) else {
return []
}
return [h.code]
}
private func pushAppUsage(_ app: Application) {
let key = app.path
for role: ScrollRole in [.dash, .toggle, .block] {
LogiCenter.shared.setUsage(source: .appScroll(key: key, role: role),
codes: collectAppScrollCodes(app: app, role: role))
}
}
private func clearAppUsage(_ app: Application) {
let key = app.path
for role: ScrollRole in [.dash, .toggle, .block] {
LogiCenter.shared.setUsage(source: .appScroll(key: key, role: role), codes: [])
}
}
Find every save path that previously called syncDivertWithBindings() and replace with pushAppUsage(app) for the affected app. For inherit-true toggle and app deletion, call clearAppUsage(app) instead. For inherit-false toggle, call pushAppUsage(app) (re-push).
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
Smoke: add an app to the per-app list with a Back-Button binding. Save. Verify Debug panel divert. Toggle inherit on. Verify divert clears. Delete app. Verify divert stays clear.
git add Mos/Windows/PreferencesWindow/ApplicationView/PreferencesApplicationViewController.swift Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingWithApplicationViewController.swift
git commit -m "refactor(logi): app scroll panel uses setUsage(.appScroll(key:role:))
Save: pushAppUsage(app) (3 roles).
Inherit-true toggle: clearAppUsage(app) (3 roles -> empty -> source removed).
Inherit-false toggle: pushAppUsage(app) (re-push).
App delete: clearAppUsage(app)."
Files:
Create: Mos/Integration/LogiUsageBootstrap.swift
Modify: Mos/AppDelegate.swift
Create: MosTests/LogiUsageBootstrapTests.swift
Step 1: Write LogiUsageBootstrap
// Mos/Integration/LogiUsageBootstrap.swift
import Foundation
/// Push initial usage from Options to LogiCenter at app launch.
/// Idempotent. Preference panels' save paths push their own slice afterward.
enum LogiUsageBootstrap {
static func refreshAll() {
// 1. Button bindings
let buttonCodes: Set<UInt16> = Set(
ButtonUtils.shared.getButtonBindings()
.filter { $0.isEnabled && $0.triggerEvent.type == .mouse }
.map { $0.triggerEvent.code }
.filter { LogiCenter.shared.isLogiCode($0) }
)
LogiCenter.shared.setUsage(source: .buttonBinding, codes: buttonCodes)
// 2. Global scroll
for role in ScrollRole.allCases {
let codes = globalScrollCodes(role: role)
LogiCenter.shared.setUsage(source: .globalScroll(role), codes: codes)
}
// 3. App scroll
let apps = Options.shared.application.applications
for i in 0..<apps.count {
guard let app = apps.get(by: i) else { continue }
for role in ScrollRole.allCases {
let codes = appScrollCodes(app: app, role: role)
LogiCenter.shared.setUsage(source: .appScroll(key: app.path, role: role), codes: codes)
}
}
}
private static func globalScrollCodes(role: ScrollRole) -> Set<UInt16> {
let hotkey: RecordedEvent? = {
switch role {
case .dash: return Options.shared.scroll.dash
case .toggle: return Options.shared.scroll.toggle
case .block: return Options.shared.scroll.block
}
}()
guard let h = hotkey, h.type == .mouse, LogiCenter.shared.isLogiCode(h.code) else { return [] }
return [h.code]
}
private static func appScrollCodes(app: Application, role: ScrollRole) -> Set<UInt16> {
guard !app.inherit else { return [] }
let hotkey: RecordedEvent? = {
switch role {
case .dash: return app.scroll.dash
case .toggle: return app.scroll.toggle
case .block: return app.scroll.block
}
}()
guard let h = hotkey, h.type == .mouse, LogiCenter.shared.isLogiCode(h.code) else { return [] }
return [h.code]
}
}
In applicationDidFinishLaunching (and the second start path in startWithAccessibilityPermissionsChecker):
Before LogiCenter.shared.start():
LogiCenter.shared.installBridge(LogiNoOpBridge.shared) // Step 4 will swap to Integration bridge
LogiUsageBootstrap.refreshAll()
LogiCenter.shared.start()
// MosTests/LogiUsageBootstrapTests.swift
import XCTest
@testable import Mos_Debug
final class LogiUsageBootstrapTests: XCTestCase {
/// Smoke: refreshAll runs without crashing and populates the registry.
/// Cannot deterministically assert content because Options.shared has live state.
func testRefreshAll_runsWithoutCrash() {
LogiUsageBootstrap.refreshAll()
// After refreshAll, registry has at least 4 sources (buttonBinding + 3 globalScroll
// entries, even if codes are empty — they're still set as empty which removes them).
// The assertion is just non-crash.
XCTAssertNotNil(LogiCenter.shared)
}
}
xcodebuild -scheme Debug -destination 'platform=macOS' test -only-testing:MosTests/UsageRegistryTests -only-testing:MosTests/UsageRegistryEndToEndTests -only-testing:MosTests/LogiUsageBootstrapTests
Expected: all PASS.
git add Mos.xcodeproj/project.pbxproj Mos/Integration/LogiUsageBootstrap.swift Mos/AppDelegate.swift MosTests/LogiUsageBootstrapTests.swift
git commit -m "feat(logi): LogiUsageBootstrap pushes initial Options state at launch
Runs in AppDelegate before LogiCenter.start(). Without this, release
builds would not divert until the user opened Preferences."
Files:
Modify: Mos/Logi/LogiSessionManager.swift
Modify: Mos/Logi/LogiDeviceSession.swift
Step 1: Confirm no remaining callers
grep -rn "syncDivertWithBindings\|collectBoundLogiMosCodes" Mos/ MosTests/ --include='*.swift'
Expected: matches only inside Mos/Logi/. If any preference panel still calls it, return to Task 3.7-3.9 and finish migration.
LogiSessionManager.syncDivertWithBindings()# manually edit LogiSessionManager.swift, remove the method
LogiDeviceSession.syncDivertWithBindings() and collectBoundLogiMosCodes()The session-level syncDivertWithBindings() is no longer the integration point — applyUsage(_:) is. Remove both methods.
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
Expected: BUILD SUCCEEDED. If failures, a caller was missed.
xcodebuild -scheme Debug -destination 'platform=macOS' test
Expected: all PASS. Then run app, attach Logi mouse, change a binding, verify divert behavior.
git add Mos/Logi/LogiSessionManager.swift Mos/Logi/LogiDeviceSession.swift
git commit -m "refactor(logi): delete syncDivertWithBindings + collectBoundLogiMosCodes
UsageRegistry + applyUsage replace the reverse-scan-Options pattern."
Files:
Modify: Mos/Logi/LogiSessionManager.swift
Step 1: Read current implementation
grep -n "func refreshReportingStatesIfNeeded\|hasAnyLogitechBinding" Mos/Logi/LogiSessionManager.swift
Old (paraphrased):
func refreshReportingStatesIfNeeded() {
let hasAny = ... // scan Options.buttons, Options.scroll, Options.application
if !hasAny { return }
// throttle ... do the actual GetControlReporting on each session
}
New:
func refreshReportingStatesIfNeeded() {
if LogiCenter.shared.registry.aggregatedCacheIsEmpty { return }
// throttle ... existing GetControlReporting trigger
}
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
git add Mos/Logi/LogiSessionManager.swift
git commit -m "refactor(logi): refreshReportingStatesIfNeeded uses registry aggregate
No more Options scan; cheaper read of UsageRegistry's pre-computed cache."
Files:
Modify: Mos/Keys/KeyRecorder.swift
Step 1: Replace recording entry/exit
sed -i '' \
-e 's/LogiSessionManager\.shared\.temporarilyDivertAll()/LogiCenter.shared.beginKeyRecording()/g' \
-e 's/LogiSessionManager\.shared\.restoreDivertToBindings()/LogiCenter.shared.endKeyRecording()/g' \
Mos/Keys/KeyRecorder.swift
In KeyRecorder.swift:211:
forName: NSNotification.Name("LogitechHIDButtonEvent"),
→
forName: LogiCenter.buttonEventRelay,
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
git add Mos/Keys/KeyRecorder.swift
git commit -m "refactor(logi): KeyRecorder uses LogiCenter recording API + notification"
Files:
Create: MosTests/LogiCenterDeviceIntegrationTests.swift
Modify: MosTests/Debug.xctestplan (and add DebugWithDevice.xctestplan)
Step 1: Write the gate base + integration test
import XCTest
@testable import Mos_Debug
class LogiDeviceIntegrationBase: XCTestCase {
static var hasDevice: Bool {
ProcessInfo.processInfo.environment["LOGI_REAL_DEVICE"] == "1"
}
override func setUpWithError() throws {
try XCTSkipUnless(Self.hasDevice, "requires LOGI_REAL_DEVICE=1")
}
}
final class LogiCenterDeviceIntegrationTests: LogiDeviceIntegrationBase {
/// Round 4 / spec §7 Tier 3a — 0 → 1 → 0 baseline transition.
/// Asserts that Mos is the actor: bit0 starts 0, becomes 1 under setUsage,
/// returns to 0 after setUsage([]).
func testBaselineTransition_BackButton() throws {
// 1. Wait for first session ready
let sessionExp = expectation(forNotification: LogiCenter.reportingDidComplete, object: nil)
LogiCenter.shared.start()
wait(for: [sessionExp], timeout: 30)
// 2. Assert baseline bit0 == 0
guard let snapshot = LogiCenter.shared.activeSessionsSnapshot().first else {
throw XCTSkip("No active session")
}
let cidBack: UInt16 = 0x0053
let baseline = readReportingBit0(snapshot: snapshot, cid: cidBack)
try XCTSkipIf(baseline == true, "Third party owns CID 0x0053; cannot assert Mos transition")
XCTAssertEqual(baseline, false)
// 3. Apply Mos divert
let onExp = expectation(forNotification: LogiCenter.reportingDidComplete, object: nil)
LogiCenter.shared.setUsage(source: .buttonBinding, codes: [1006]) // MosCode for Back
wait(for: [onExp], timeout: 30)
XCTAssertEqual(readReportingBit0(snapshot: snapshot, cid: cidBack), true)
// 4. Clear
let offExp = expectation(forNotification: LogiCenter.reportingDidComplete, object: nil)
LogiCenter.shared.setUsage(source: .buttonBinding, codes: [])
wait(for: [offExp], timeout: 30)
XCTAssertEqual(readReportingBit0(snapshot: snapshot, cid: cidBack), false)
}
/// Returns reportingFlags bit0 for a CID in the snapshot's discovered controls.
private func readReportingBit0(snapshot: LogiDeviceSessionSnapshot, cid: UInt16) -> Bool {
// LogiDeviceSessionSnapshot needs a `discoveredControls` accessor.
// Add to snapshot if missing (Step 5 cleanup).
// For now, read via Logi internals — Step 4 may refine.
return false // placeholder; refine snapshot API in Step 4 if needed
}
}
discoveredControls to snapshot if missingIn LogiDeviceSessionSnapshot:
public let discoveredControls: [LogiDeviceSession.ControlInfo]
Update init:
internal init(session: LogiDeviceSession) {
self.connectionMode = session.connectionMode
self.deviceInfo = session.deviceInfo
self.pairedDevices = session.debugReceiverPairedDevices
self.discoveredControls = session.debugDiscoveredControls
}
Then update test helper:
private func readReportingBit0(snapshot: LogiDeviceSessionSnapshot, cid: UInt16) -> Bool {
guard let ctrl = snapshot.discoveredControls.first(where: { $0.cid == cid }) else { return false }
return (ctrl.reportingFlags & 0x01) != 0
}
Copy MosTests/Debug.xctestplan → MosTests/DebugWithDevice.xctestplan and ensure the latter sets LOGI_REAL_DEVICE=1 in the environment.
LOGI_REAL_DEVICE=1 xcodebuild -scheme Debug -testPlan DebugWithDevice -destination 'platform=macOS' test -only-testing:MosTests/LogiCenterDeviceIntegrationTests
Expected: PASS or SKIP (if Options+ owns the CID). If FAIL, debug.
git add MosTests/LogiCenterDeviceIntegrationTests.swift MosTests/DebugWithDevice.xctestplan Mos/Logi/LogiDeviceSessionSnapshot.swift
git commit -m "test(logi): real-device 0->1->0 baseline integration test"
codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check "$(cat <<'PROMPT'
Review Step 3 commits. Verify:
- syncDivertWithBindings + collectBoundLogiMosCodes deleted; no caller
survived (grep across Mos/ and MosTests/).
- All 5 preference panels migrated to setUsage; specifically check
PreferencesScrollingViewController lines that previously called
syncDivertWithBindings (~5 sites).
- App delete + inherit-true + inherit-false transitions all clear/re-push
correctly.
- LogiUsageBootstrap.refreshAll runs before LogiCenter.start in AppDelegate.
- LogiDeviceSession.applyUsage projects MosCode -> CID via
LogiCIDDirectory.toCID; intersects with divertable CIDs.
- 6 prime hooks all wired (divertBoundControls, rediscoverFeatures,
setTargetSlot, restoreDivertToBindings, redivertAllControls, recompute).
- refreshReportingStatesIfNeeded uses registry, not Options scan.
- Tier 3a baseline test passes on real device.
Report file:line + severity. Be terse.
PROMPT
)"
Round 2 closure check after fixes.
LogiExternalBridge filled out, LogiIntegrationBridge provides production routing, LogiDeviceSession.dispatchButtonEvent rewritten. After this step, Mos/Logi/ no longer imports ScrollCore / ButtonUtils / InputProcessor / Toast.
Files:
Modify: Mos/Logi/LogiCenter.swift (rawButtonEvent already exists from Task 2.2 step 1; verify name)
Modify: Mos/Logi/LogiDeviceSession.swift
Step 1: Rewrite dispatchButtonEvent body
In LogiDeviceSession.swift, find private func dispatchButtonEvent(cid: UInt16, isDown: Bool) and replace body with the form from spec §4.4:
private func dispatchButtonEvent(cid: UInt16, isDown: Bool) {
let currentFlags = CGEventSource.flagsState(.combinedSessionState)
let event = InputEvent(
type: .mouse,
code: LogiCIDDirectory.toMosCode(cid),
modifiers: currentFlags,
phase: isDown ? .down : .up,
source: .hidPP,
device: deviceInfo
)
// Always-fired raw event (deterministic for wizard + debug observers)
NotificationCenter.default.post(
name: LogiCenter.rawButtonEvent,
object: nil,
userInfo: [
"event": event,
"mosCode": event.code,
"cid": cid,
"phase": isDown ? "down" : "up",
])
let bridge = LogiCenter.shared.externalBridge
if LogiCenter.shared.isRecording {
_ = bridge.dispatchLogiButtonEvent(event)
return
}
// Side path: scroll hotkey fires regardless of binding outcome.
bridge.handleLogiScrollHotkey(code: event.code, phase: event.phase)
// Main routing.
switch bridge.dispatchLogiButtonEvent(event) {
case .logiAction(let name) where event.phase == .down:
executeLogiAction(name)
case .consumed, .unhandled, .logiAction:
break
}
}
.up invariant on state-reset pathsAdd private method:
private func emitScrollHotkeyReleaseForActiveCIDs() {
let bridge = LogiCenter.shared.externalBridge
for cid in lastActiveCIDs {
let mosCode = LogiCIDDirectory.toMosCode(cid)
bridge.handleLogiScrollHotkey(code: mosCode, phase: .up)
}
lastActiveCIDs.removeAll()
self.lastApplied.removeAll()
}
Call from teardown, setTargetSlot, rediscoverFeatures. (Was previously inline in teardown only.)
It may fail on bridge.dispatchLogiButtonEvent not existing. The protocol stub from Task 2.1 already declares it but the NoOp impl always returns .unhandled. Should compile.
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
If build fails, audit protocol declarations match call sites.
git add Mos/Logi/LogiDeviceSession.swift
git commit -m "feat(logi): LogiDeviceSession.dispatchButtonEvent uses bridge + rawButtonEvent
Posts rawButtonEvent unconditionally with mosCode/cid/phase before any
routing. Recording short-circuits via bridge. Non-recording: scroll hotkey
side path + main routing via bridge.dispatchLogiButtonEvent. logiAction
(.down) executes locally for device isolation."
Files:
Create: Mos/Integration/LogiIntegrationBridge.swift
Step 1: Write the production bridge
// Mos/Integration/LogiIntegrationBridge.swift
import Foundation
/// Production LogiExternalBridge implementation.
/// Routes Logi events to ScrollCore, ButtonUtils, InputProcessor, Toast.
final class LogiIntegrationBridge: LogiExternalBridge {
static let shared = LogiIntegrationBridge()
private init() {}
func dispatchLogiButtonEvent(_ event: InputEvent) -> LogiDispatchResult {
if LogiCenter.shared.isRecording {
NotificationCenter.default.post(
name: LogiCenter.buttonEventRelay, object: nil, userInfo: ["event": event])
return .consumed
}
// Probe for logi* binding; return name for session to execute.
if event.phase == .down,
let binding = ButtonUtils.shared.getBestMatchingBinding(
for: event,
where: { $0.systemShortcutName.hasPrefix("logi") }) {
return .logiAction(name: binding.systemShortcutName)
}
// Generic binding via InputProcessor.
let result = InputProcessor.shared.process(event)
if result == .consumed { return .consumed }
// Unconsumed: post relay.
NotificationCenter.default.post(
name: LogiCenter.buttonEventRelay, object: nil, userInfo: ["event": event])
return .unhandled
}
func handleLogiScrollHotkey(code: UInt16, phase: InputPhase) {
ScrollCore.shared.handleScrollHotkey(code: code, isDown: phase == .down)
}
func showLogiToast(_ message: String, severity: LogiToastSeverity) {
let style: Toast.Style
switch severity {
case .info: style = .info
case .warning: style = .warning
case .error: style = .error
}
Toast.show(message, style: style)
}
}
In LogiDeviceSession.swift find showFeatureNotAvailable (around line 1197 currently calling Toast.show(message, style: .warning)):
private func showFeatureNotAvailable(_ message: String) {
LogiCenter.shared.externalBridge.showLogiToast(message, severity: .warning)
}
Audit:
grep -rn "^import \(ScrollCore\|ButtonUtils\|InputProcessor\|Toast\)\|ScrollCore\.shared\|ButtonUtils\.shared\|InputProcessor\.shared\|^import .*Components" Mos/Logi/ --include='*.swift'
Wait — these are not module imports (Mos is a single target), they are direct symbol references. Audit:
grep -rn "ScrollCore\.shared\|ButtonUtils\.shared\|InputProcessor\.shared\|Toast\.show" Mos/Logi/ --include='*.swift'
After Task 4.1 + 4.2, the only remaining references should be:
ScrollCore.shared.handleScrollHotkey — removed (now in IntegrationBridge)ButtonUtils.shared.getBestMatchingBinding — removed (now in IntegrationBridge)InputProcessor.shared.process — removed (now in IntegrationBridge)Toast.show — removed (now in IntegrationBridge)If any survive, refactor them to use the bridge.
In applicationDidFinishLaunching:
// Before:
LogiCenter.shared.installBridge(LogiNoOpBridge.shared)
// After:
LogiCenter.shared.installBridge(LogiIntegrationBridge.shared)
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
Run app. Connect Logi mouse. Click Back button (with binding). Verify action triggered. Trigger feature-not-available toast. Verify it shows.
git add -A
git commit -m "feat(logi): LogiIntegrationBridge production impl + dependency inversion
Mos/Logi/ no longer references ScrollCore / ButtonUtils / InputProcessor /
Toast. All such routing lives in Mos/Integration/LogiIntegrationBridge.
AppDelegate installs LogiIntegrationBridge.shared at launch."
Files:
Create: MosTests/LogiTestDoubles/FakeLogiExternalBridge.swift
Create: MosTests/LogiBridgeDispatchTests.swift
Create: MosTests/LogiTeardownTests.swift
Step 1: Write fake bridge
// MosTests/LogiTestDoubles/FakeLogiExternalBridge.swift
import Foundation
@testable import Mos_Debug
final class FakeLogiExternalBridge: LogiExternalBridge {
enum Call: Equatable {
case dispatch(InputEvent)
case scrollHotkey(code: UInt16, phase: InputPhase)
case toast(String, LogiToastSeverity)
}
var calls: [Call] = []
var dispatchReturn: LogiDispatchResult = .unhandled
func dispatchLogiButtonEvent(_ event: InputEvent) -> LogiDispatchResult {
calls.append(.dispatch(event))
return dispatchReturn
}
func handleLogiScrollHotkey(code: UInt16, phase: InputPhase) {
calls.append(.scrollHotkey(code: code, phase: phase))
}
func showLogiToast(_ message: String, severity: LogiToastSeverity) {
calls.append(.toast(message, severity))
}
}
These exercise the routing decision tree per spec §4.4. Cannot trivially construct a LogiDeviceSession in unit tests, so test the bridge in isolation by calling dispatchLogiButtonEvent directly:
import XCTest
@testable import Mos_Debug
final class LogiBridgeDispatchTests: XCTestCase {
func testRecordingMode_returnsConsumedAndPostsRelay() {
// Mock: enable LogiCenter.shared.isRecording, run real bridge.
// Skipped — requires LogiCenter mocking. Covered in integration.
}
func testFakeBridgeRecordsCalls() {
let fake = FakeLogiExternalBridge()
let event = InputEvent(type: .mouse, code: 1006, modifiers: [], phase: .down, source: .hidPP, device: nil)
fake.dispatchReturn = .logiAction(name: "logiSmartShiftToggle")
let result = fake.dispatchLogiButtonEvent(event)
XCTAssertEqual(result, .logiAction(name: "logiSmartShiftToggle"))
XCTAssertEqual(fake.calls.count, 1)
}
}
(Comprehensive routing tests live in Tier 3a where a real session can drive the bridge end-to-end.)
import XCTest
@testable import Mos_Debug
final class LogiTeardownTests: XCTestCase {
/// Spec §4.4 / Round 4 M2 — emit `.up` via bridge before clearing per-session
/// state on each of: teardown, setTargetSlot, rediscoverFeatures, LogiCenter.stop.
/// Cannot construct LogiDeviceSession easily in unit tests; this case is
/// covered by Tier 3a real-device test (LogiBridgeDeviceTests).
func test_pathsCovered_byTier3a() {
// Smoke marker: ensures this file exists for the test plan.
XCTAssertTrue(true)
}
}
(Real coverage: Tier 3a.)
xcodebuild -scheme Debug -destination 'platform=macOS' test
Expected: all PASS.
git add MosTests/LogiTestDoubles/FakeLogiExternalBridge.swift MosTests/LogiBridgeDispatchTests.swift MosTests/LogiTeardownTests.swift
git commit -m "test(logi): bridge dispatch + teardown harness scaffolding"
Files:
Create: MosTests/LogiBridgeDeviceTests.swift
Create: MosTests/LogiFeatureActionDeviceTests.swift
Step 1: Bridge end-to-end
import XCTest
@testable import Mos_Debug
final class LogiBridgeDeviceTests: LogiDeviceIntegrationBase {
/// Validates that pressing a real button on the connected Logi device
/// triggers rawButtonEvent post + bridge.dispatchLogiButtonEvent in correct order.
/// Test is interactive — uses XCTestExpectation with manual press.
func testRealButtonPressTriggersRawEvent() {
let exp = expectation(forNotification: LogiCenter.rawButtonEvent, object: nil) { notif in
return (notif.userInfo?["mosCode"] as? UInt16) == 1006
}
// Tester must press Back button within 30s.
wait(for: [exp], timeout: 30)
}
}
(This test requires user interaction; consider removing from CI or marking with a note.)
import XCTest
@testable import Mos_Debug
final class LogiFeatureActionDeviceTests: LogiDeviceIntegrationBase {
func testExecuteDPICycle_changesRegister() {
// 1. Wait for session ready, capture baseline DPI.
let ready = expectation(forNotification: LogiCenter.reportingDidComplete, object: nil)
LogiCenter.shared.start()
wait(for: [ready], timeout: 30)
guard let snapshot = LogiCenter.shared.activeSessionsSnapshot().first else {
throw XCTSkip("No active session")
}
// baseline read via Snapshot's DPI accessor (add if missing)
// ...
LogiCenter.shared.executeDPICycle(direction: .up)
// wait for some DPI change notification, assert change
}
}
(Refine based on actual DPI register APIs.)
LOGI_REAL_DEVICE=1 xcodebuild -scheme Debug -testPlan DebugWithDevice -destination 'platform=macOS' test
git add MosTests/LogiBridgeDeviceTests.swift MosTests/LogiFeatureActionDeviceTests.swift
git commit -m "test(logi): real-device bridge round-trip + feature action"
codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check "$(cat <<'PROMPT'
Review Step 4 commits. Verify:
- Mos/Logi/ does NOT reference ScrollCore.shared, ButtonUtils.shared,
InputProcessor.shared, or Toast.show (grep entire dir).
- LogiDeviceSession.dispatchButtonEvent posts rawButtonEvent unconditionally
with mosCode + cid + phase keys.
- Recording short-circuit calls bridge.dispatchLogiButtonEvent and returns,
not triggering scroll hotkey or main routing.
- emitScrollHotkeyReleaseForActiveCIDs is called at all 4 sites: teardown,
setTargetSlot, rediscoverFeatures, LogiCenter.stop.
- AppDelegate installs LogiIntegrationBridge.shared (NOT NoOp) before start().
Report file:line + severity. Be terse.
PROMPT
)"
Round 2 closure.
Files:
Move within Mos/Logi/.
Step 1: Create subdirs and move
cd Mos/Logi
mkdir Core Usage Divert Bridge Debug
git mv LogiDeviceSession.swift LogiSessionManager.swift LogiCIDDirectory.swift LogiReceiverCatalog.swift SessionActivityStatus.swift LogiDeviceSessionSnapshot.swift Core/
git mv UsageRegistry.swift UsageSource.swift Usage/
git mv DivertPlanner.swift ConflictDetector.swift Divert/
git mv LogiExternalBridge.swift LogiNoOpBridge.swift Bridge/
git mv LogiDebugPanel.swift BrailleSpinner.swift Debug/
cd ../..
Open Mos.xcodeproj in Xcode. Reorganize the file groups under Logi to match the folder layout. Adjust pbxproj as needed.
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
Expected: BUILD SUCCEEDED.
git add -A
git commit -m "refactor(logi): organize Mos/Logi/ into Core/Usage/Divert/Bridge/Debug subdirs"
Files:
Modify: Mos/Logi/Divert/ConflictDetector.swift
Modify: Mos/Logi/Debug/LogiDebugPanel.swift
Modify: Mos/Windows/PreferencesWindow/ButtonsView/ButtonTableCellView.swift
Create: MosTests/LogiConflictDetectorTests.swift
Step 1: Rewrite ConflictStatus + detector
// Mos/Logi/Divert/ConflictDetector.swift
public enum ConflictStatus {
case clear
case foreignDivert
case remapped
case mosOwned
case unknown
/// Legacy boolean adapter for callers that previously checked == .conflict.
public var isConflict: Bool {
switch self {
case .foreignDivert, .remapped: return true
case .clear, .mosOwned, .unknown: return false
}
}
}
enum LogiConflictDetector {
/// Round 4 H2: precedence order matches LogiDebugPanel.swift:2107-2122
/// (foreign > remap > mos > clear). Reordering changes user-visible status.
static func status(reportingFlags: UInt8,
targetCID: UInt16,
cid: UInt16,
reportingQueried: Bool,
mosOwnsDivert: Bool) -> ConflictStatus {
guard reportingQueried else { return .unknown }
let isForeignDivert = reportingFlags != 0 && !mosOwnsDivert
if isForeignDivert { return .foreignDivert }
let isRemapped = targetCID != 0 && targetCID != cid
if isRemapped { return .remapped }
if mosOwnsDivert { return .mosOwned }
return .clear
}
}
In LogiDebugPanel.swift (around line 2089–2122), replace inline boolean computation with:
let mosOwns = currentSession?.debugDivertedCIDs.contains(ctrl.cid) ?? false
let status = LogiConflictDetector.status(
reportingFlags: ctrl.reportingFlags,
targetCID: ctrl.targetCID,
cid: ctrl.cid,
reportingQueried: ctrl.reportingQueried,
mosOwnsDivert: mosOwns
)
case "cStatus":
switch status {
case .foreignDivert:
label.stringValue = "3rd-DVRT"
label.textColor = NSColor(calibratedRed: 1.0, green: 0.3, blue: 0.3, alpha: 0.9)
case .remapped:
label.stringValue = "REMAP"
label.textColor = NSColor(calibratedRed: 1.0, green: 0.8, blue: 0.2, alpha: 0.8)
case .mosOwned:
label.stringValue = "DVRT"
label.textColor = NSColor(calibratedRed: 1.0, green: 0.6, blue: 0.0, alpha: 0.8)
case .clear:
label.stringValue = "\u{25CF}"
label.textColor = NSColor(calibratedRed: 0.3, green: 0.8, blue: 0.4, alpha: 1.0)
case .unknown:
label.stringValue = "?"
label.textColor = .tertiaryLabelColor
}
sed -i '' 's/status == \.conflict/status.isConflict/g' Mos/Windows/PreferencesWindow/ButtonsView/ButtonTableCellView.swift
Verify:
grep -n "status\." Mos/Windows/PreferencesWindow/ButtonsView/ButtonTableCellView.swift
// MosTests/LogiConflictDetectorTests.swift
import XCTest
@testable import Mos_Debug
final class LogiConflictDetectorTests: XCTestCase {
func testNotQueried_unknown() {
let s = LogiConflictDetector.status(reportingFlags: 0, targetCID: 0, cid: 0x0053, reportingQueried: false, mosOwnsDivert: false)
XCTAssertEqual(s, .unknown)
}
func testForeignDivert_flagsNonZero_notMos() {
let s = LogiConflictDetector.status(reportingFlags: 0x01, targetCID: 0, cid: 0x0053, reportingQueried: true, mosOwnsDivert: false)
XCTAssertEqual(s, .foreignDivert)
XCTAssertTrue(s.isConflict)
}
func testMosOwned_flagsNonZero_butMos() {
let s = LogiConflictDetector.status(reportingFlags: 0x01, targetCID: 0, cid: 0x0053, reportingQueried: true, mosOwnsDivert: true)
XCTAssertEqual(s, .mosOwned)
XCTAssertFalse(s.isConflict)
}
func testRemapped_targetDiffers() {
let s = LogiConflictDetector.status(reportingFlags: 0, targetCID: 0x0050, cid: 0x0053, reportingQueried: true, mosOwnsDivert: false)
XCTAssertEqual(s, .remapped)
XCTAssertTrue(s.isConflict)
}
func testForeignBeatsRemap_whenBothPresent() {
let s = LogiConflictDetector.status(reportingFlags: 0x01, targetCID: 0x0050, cid: 0x0053, reportingQueried: true, mosOwnsDivert: false)
XCTAssertEqual(s, .foreignDivert, "Foreign divert takes precedence over remap when both present")
}
func testClear_allZero() {
let s = LogiConflictDetector.status(reportingFlags: 0, targetCID: 0, cid: 0x0053, reportingQueried: true, mosOwnsDivert: false)
XCTAssertEqual(s, .clear)
XCTAssertFalse(s.isConflict)
}
func testSelfRemap_isClear() {
let s = LogiConflictDetector.status(reportingFlags: 0, targetCID: 0x0053, cid: 0x0053, reportingQueried: true, mosOwnsDivert: false)
XCTAssertEqual(s, .clear)
}
}
xcodebuild -scheme Debug -destination 'platform=macOS' test -only-testing:MosTests/LogiConflictDetectorTests
Expected: all PASS.
git add -A
git commit -m "refactor(logi): ConflictStatus 5-state with isConflict adapter
Precedence aligned with LogiDebugPanel: foreign > remap > mos > clear.
LogiDebugPanel Status column now calls LogiConflictDetector.status.
ButtonTableCellView migrated from ==.conflict to .isConflict."
Files:
Create: scripts/lint-logi-boundary.sh
Create: MosTests/LogiBoundaryEnforcementTests.swift
Step 1: Write lint script
#!/usr/bin/env bash
# scripts/lint-logi-boundary.sh
# Enforces module boundary because same-target `internal` is not enough.
set -euo pipefail
# Zone A: outside Mos/Logi/ AND Mos/Integration/
ZONE_A_ALLOW=(LogiCenter UsageSource ScrollRole ConflictStatus Direction LogiDeviceSessionSnapshot SessionActivityStatus)
# Zone B: inside Mos/Integration/ only
ZONE_B_ADDITIONAL=(LogiExternalBridge LogiDispatchResult LogiToastSeverity LogiNoOpBridge LogiUsageBootstrap)
VIOLATIONS=0
# Zone A scan
ZONE_A_FILES=$(find Mos -type f -name '*.swift' -not -path 'Mos/Logi/*' -not -path 'Mos/Integration/*')
for f in $ZONE_A_FILES; do
while IFS= read -r line_num_match; do
line_num=${line_num_match%%:*}
line=${line_num_match#*:}
# Find Logi*/Logitech* symbols (rough heuristic)
for symbol in $(echo "$line" | grep -oE '\b(Logi[A-Z][a-zA-Z]*|Logitech[A-Z][a-zA-Z]*)\b' | sort -u); do
allowed=false
for allow in "${ZONE_A_ALLOW[@]}"; do
if [[ "$symbol" == "$allow" ]]; then allowed=true; break; fi
done
if [[ "$allowed" == "false" ]]; then
echo "VIOLATION (zone A): $f:$line_num references '$symbol'"
VIOLATIONS=$((VIOLATIONS + 1))
fi
done
done < <(grep -nE '\b(Logi[A-Z]|Logitech[A-Z])' "$f" || true)
done
if [ "$VIOLATIONS" -gt 0 ]; then
echo "Lint failed: $VIOLATIONS Logi boundary violations."
exit 1
fi
echo "Lint passed: zone A allowlist enforced."
chmod +x scripts/lint-logi-boundary.sh
./scripts/lint-logi-boundary.sh
Expected: PASS.
// MosTests/LogiBoundaryEnforcementTests.swift
import XCTest
final class LogiBoundaryEnforcementTests: XCTestCase {
func testBoundaryLint_passes() throws {
let process = Process()
process.launchPath = "/bin/bash"
process.arguments = ["scripts/lint-logi-boundary.sh"]
process.currentDirectoryPath = SourceRoot.path
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
process.launch()
process.waitUntilExit()
let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
XCTAssertEqual(process.terminationStatus, 0, "Logi boundary lint failed:\n\(output)")
}
}
private enum SourceRoot {
static var path: String { return URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent().path }
}
xcodebuild -scheme Debug -destination 'platform=macOS' test -only-testing:MosTests/LogiBoundaryEnforcementTests
git add scripts/lint-logi-boundary.sh MosTests/LogiBoundaryEnforcementTests.swift
git commit -m "test(logi): CI lint enforces zone allowlists for Logi symbols"
Files:
Create: Mos/Logi/Debug/LogiSelfTestRunner.swift
Create: Mos/Logi/Debug/LogiSelfTestWizard.swift
Modify: Mos/Managers/StatusItemManager.swift (add menu item, DEBUG only)
Step 1: Write LogiSelfTestRunner skeleton
// Mos/Logi/Debug/LogiSelfTestRunner.swift
#if DEBUG
import Foundation
enum StepKind {
case automatic(detail: String, run: () async throws -> StepOutcome)
case physicalAutoVerified(instruction: String, expectation: String,
wait: WaitCondition, timeout: TimeInterval)
case physicalUserConfirmed(instruction: String, expectation: String,
confirmPrompt: String)
}
enum WaitCondition {
case rawButtonEvent(mosCode: UInt16?, cid: UInt16?)
case sessionConnected(mode: LogiDeviceSession.ConnectionMode)
case sessionDisconnected
case divertApplied(cid: UInt16, expectBit0: Bool)
case dpiChanged(direction: Direction)
}
enum StepOutcome { case pass, fail(reason: String) }
final class LogiSelfTestRunner {
func detectConnection() -> DetectedConnection? {
guard let snapshot = LogiCenter.shared.activeSessionsSnapshot().first else { return nil }
switch snapshot.connectionMode {
case .receiver:
guard let firstConnected = snapshot.pairedDevices.first(where: { $0.isConnected }) else { return nil }
return .bolt(snapshot: snapshot, slot: firstConnected.slot, name: firstConnected.name)
case .bleDirect:
return .bleDirect(snapshot: snapshot, name: snapshot.deviceInfo.name)
case .unsupported:
return nil
}
}
// Implement: buildBoltSuite() / buildBLESuite() / runStep(_:) / handleCancel()
// ... ~300 LOC; full implementation deferred to per-step writeup
}
enum DetectedConnection {
case bolt(snapshot: LogiDeviceSessionSnapshot, slot: UInt8, name: String)
case bleDirect(snapshot: LogiDeviceSessionSnapshot, name: String)
}
#endif
// Mos/Logi/Debug/LogiSelfTestWizard.swift
#if DEBUG
import Cocoa
final class LogiSelfTestWizard {
static let shared = LogiSelfTestWizard()
private var window: NSWindow?
func show() {
// Open a window with a step indicator, instruction text, expectation text,
// optional buttons (skip / retry / abort), progress (Step N of M).
// Wires LogiSelfTestRunner to drive steps.
// ~150 LOC; full implementation per spec §7 Tier 3c.
}
}
#endif
(Detailed Bolt/BLE step lists in spec §7. Implementation deferred to per-step writeup; test by running the wizard manually.)
In StatusItemManager.swift:
#if DEBUG
let selfTestItem = NSMenuItem(title: "Logi Self-Test...", action: #selector(showLogiSelfTest), keyEquivalent: "")
menu.addItem(selfTestItem)
#endif
#if DEBUG
@objc private func showLogiSelfTest() {
LogiSelfTestWizard.shared.show()
}
#endif
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build
Run app. Click status menu → "Logi Self-Test...". Verify window opens. Run a step (e.g. Bolt step 1 detect). Verify it auto-advances.
git add Mos.xcodeproj/project.pbxproj Mos/Logi/Debug/LogiSelfTestRunner.swift Mos/Logi/Debug/LogiSelfTestWizard.swift Mos/Managers/StatusItemManager.swift
git commit -m "feat(logi): Self-Test Wizard skeleton (DEBUG only)
LogiSelfTestRunner exposes step kinds + wait conditions per spec §7 Tier 3c.
LogiSelfTestWizard hosts an AppKit window driven by the runner.
StatusItemManager adds 'Logi Self-Test...' menu item, gated by DEBUG."
codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check "$(cat <<'PROMPT'
Final review on Step 5. Verify:
- Mos/Logi/ subdir layout matches spec §4.1.
- ConflictDetector 5-state logic correct; precedence foreign > remap > mos > clear.
- LogiDebugPanel Status column uses LogiConflictDetector.status.
- ButtonTableCellView uses status.isConflict, not == .conflict.
- scripts/lint-logi-boundary.sh allowlists match spec §11.
- Self-Test Wizard menu item is DEBUG-only.
- Boundary lint passes on the current tree.
Report file:line + severity. Be terse.
PROMPT
)"
Round 2 closure.
After all 6 steps land:
./scripts/lint-logi-boundary.sh — must pass.xcodebuild -scheme Debug -destination 'platform=macOS' test — Tier 1 + Tier 2 all green.LOGI_REAL_DEVICE=1 xcodebuild -scheme Debug -testPlan DebugWithDevice -destination 'platform=macOS' test — Tier 3 green with device.UserDefaults["logitechFeatureCache"] still loads (smoke: connect a device, verify Debug panel shows feature index without re-discovery).installBridge(LogiIntegrationBridge.shared) → LogiUsageBootstrap.refreshAll() → LogiCenter.shared.start().Logitech* symbols outside Mos/Logi/ and Mos/Integration/ (excluding comments mentioning the third-party "Logitech Options+" app).