Back to Mos

Logi Module Consolidation — Implementation Plan

docs/superpowers/plans/2026-04-25-logi-module-consolidation.md

4.2.0116.1 KB
Original Source

Logi Module Consolidation — Implementation Plan

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.
  • Hot-path NotificationCenter posts capped at exactly two: rawButtonEvent (always) + buttonEventRelay (recording or unconsumed only).
  • LogiCenter.externalBridge is strong, non-optional. Never weak.
  • LogiDeviceSession.handleInputReport accepts UnsafeBufferPointer<UInt8>, not [UInt8].
  • All Logi work is main-thread-only (DEBUG precondition(Thread.isMainThread)).
  • Codex code review × 2 at gpt-5.5 + xhigh per commit.

File Structure

New files

PathResponsibility
Mos/Logi/LogiCenter.swiftSole 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.swiftenum UsageSource { case buttonBinding, globalScroll(ScrollRole), appScroll(key: String, role: ScrollRole) }, enum ScrollRole { case dash, toggle, block }.
Mos/Logi/Usage/UsageRegistry.swiftsetUsage push API with main-async coalesced recompute. Per-app source lifecycle. Idempotent short-circuit. Empty codes removes source.
Mos/Logi/Bridge/LogiExternalBridge.swiftinternal protocol LogiExternalBridge: AnyObject { dispatchLogiButtonEvent / handleLogiScrollHotkey / showLogiToast } + internal enum LogiDispatchResult { .consumed, .unhandled, .logiAction(name:) } + internal enum LogiToastSeverity.
Mos/Logi/Bridge/LogiNoOpBridge.swiftDefault bridge before Step 4 wires the production impl.
Mos/Logi/Debug/LogiSelfTestRunner.swiftDEBUG-only step runner: enum StepKind, WaitCondition, async exec engine with cancellation tokens.
Mos/Logi/Debug/LogiSelfTestWizard.swiftDEBUG-only AppKit window hosting the wizard UI; Bolt + BLE suites.
Mos/Integration/LogiIntegrationBridge.swiftProduction LogiExternalBridge impl. Imports ScrollCore / ButtonUtils / InputProcessor / Toast. .shared singleton.
Mos/Integration/LogiUsageBootstrap.swiftOne-shot startup refreshAll() that pushes Options state into LogiCenter.
scripts/lint-logi-boundary.shBash lint enforcing zone-A (outside) and zone-B (Integration) symbol allowlists.
MosTests/LogiTestDoubles/FakeLogiSessionManager.swiftTier 2 test double.
MosTests/LogiTestDoubles/FakeLogiDeviceSession.swiftTier 2 test double with realistic divertedCIDs / divertableCIDs / lastApplied / planner-equivalent applyUsage.
MosTests/LogiTestDoubles/FakeLogiExternalBridge.swiftTier 2 test double recording call sequence + programmable returns.
MosTests/LogiPersistenceCanaryTests.swiftHard-coded golden list of frozen UserDefaults keys + autosave names.
MosTests/LogiCIDDirectoryTests.swifttoCID / toMosCode bidirectional symmetry.
MosTests/LogiCenterPublicSurfaceTests.swiftSmoke each LogiCenter public method.
MosTests/LogiCenterHarnessTests.swiftInjectable init + lifecycle + notification contracts.
MosTests/UsageRegistryTests.swiftDiff algorithm, coalescing guard, empty-codes removal, app-scroll lifecycle (delete / inherit-true / inherit-false).
MosTests/UsageRegistryEndToEndTests.swiftAll 6 prime hooks + reconnect-no-diff with FakeLogiDeviceSession.
MosTests/LogiUsageBootstrapTests.swiftBootstrap reads Options and pushes one setUsage per source; idempotent.
MosTests/LogiBridgeDispatchTests.swiftRecording short-circuit, .logiAction routing, .consumed paths, rawButtonEvent always posted.
MosTests/LogiTeardownTests.swiftAll four .up paths emit handleLogiScrollHotkey(phase: .up) via bridge.
MosTests/LogiConflictDetectorTests.swiftAll 5 ConflictStatus cases + isConflict adapter.
MosTests/LogiBoundaryEnforcementTests.swiftGreps source tree, asserts no forbidden Logi symbols outside zone allowlists.
MosTests/LogiCenterDeviceIntegrationTests.swiftTier 3a — 0 → 1 → 0 baseline transition. Gated by LOGI_REAL_DEVICE=1.
MosTests/LogiFeatureActionDeviceTests.swiftTier 3b — executeDPICycle(.up) reads back register change.
MosTests/LogiBridgeDeviceTests.swiftTier 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.

Renamed files

FromTo
Mos/LogitechHID/LogitechDeviceSession.swiftMos/Logi/Core/LogiDeviceSession.swift
Mos/LogitechHID/LogitechHIDManager.swiftMos/Logi/Core/LogiSessionManager.swift
Mos/LogitechHID/LogitechCIDRegistry.swiftMos/Logi/Core/LogiCIDDirectory.swift
Mos/LogitechHID/LogitechReceiverRegistry.swiftMos/Logi/Core/LogiReceiverCatalog.swift
Mos/LogitechHID/SessionActivityStatus.swiftMos/Logi/Core/SessionActivityStatus.swift
Mos/LogitechHID/LogitechDivertPlanner.swiftMos/Logi/Divert/DivertPlanner.swift
Mos/LogitechHID/LogitechConflictDetector.swiftMos/Logi/Divert/ConflictDetector.swift
Mos/LogitechHID/LogitechHIDDebugPanel.swiftMos/Logi/Debug/LogiDebugPanel.swift
Mos/LogitechHID/BrailleSpinner.swiftMos/Logi/Debug/BrailleSpinner.swift
MosTests/LogitechDivertPlannerTests.swiftMosTests/LogiDivertPlannerTests.swift
MosTests/LogitechConflictDetectorTests.swiftMosTests/LogiConflictDetectorTests.swift

(Step 1 keeps the dir flat; subdirs introduced in Step 5.)

Modified existing files (high-level)

PathWhy
Mos/AppDelegate.swiftReplace LogitechHIDManager.shared.start/stop with LogiCenter.shared.start/stop; add installBridge + LogiUsageBootstrap.refreshAll (Step 3+).
Mos/Shortcut/ShortcutExecutor.swiftReplace LogitechHIDManager.shared.executeSmartShiftToggle/executeDPICycle with LogiCenter.shared.*.
Mos/Managers/StatusItemManager.swift:107Replace LogitechHIDDebugPanel.shared.show() with LogiCenter.shared.showDebugPanel().
Mos/InputEvent/InputEvent.swiftLogitechCIDRegistry.{isLogitechCode,name(forMosCode:)}LogiCenter.shared.*.
Mos/Components/BrandTag.swiftSame.
Mos/Windows/PreferencesWindow/PreferencesWindowController.swift:35Replace LogitechHIDManager.shared.refreshReportingStatesIfNeeded().
Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swiftsyncDivertWithBindings()setUsage(.buttonBinding, codes:). Activity / busy / refreshReporting via facade.
Mos/Windows/PreferencesWindow/ButtonsView/ButtonTableCellView.swift:78,219,224,225CID directory + conflictStatus + activity notification → facade. ==.conflict.isConflict (Step 5).
Mos/Windows/PreferencesWindow/ButtonsView/RecordedEvent.swiftCID directory → facade.
Mos/Windows/PreferencesWindow/ButtonsView/ActionDisplayResolver.swiftCID directory → facade.
Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingViewController.swiftsyncDivertWithBindings() (5 sites) → setUsage(.globalScroll(role), codes:) and setUsage(.appScroll(key:role:), codes:); CID directory → facade.
Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingWithApplicationViewController.swiftSame pattern.
Mos/Windows/PreferencesWindow/ApplicationView/PreferencesApplicationViewController.swiftSame; plus delete / inherit toggle clears appScroll(key:role:) sources.
Mos/Keys/KeyRecorder.swift:131,210-222,521temporarilyDivertAll/restoreDivertToBindingsLogiCenter.shared.beginKeyRecording/endKeyRecording; "LogitechHIDButtonEvent" literal → LogiCenter.buttonEventRelay.
Mos/ScrollCore/ScrollCore.swift:199Rename handleScrollHotkeyFromHIDPlusPlushandleScrollHotkey.

Step 0: Pre-refactor cleanup

Two pre-existing bugs that this refactor depends on. Land before Step 1 to avoid mixing semantic fix with rename diff.

Task 0.1: Remove per-input-report [UInt8] heap allocation

Files:

  • Modify: Mos/LogitechHID/LogitechDeviceSession.swift:316-321 (callback) and :1190-... (handleInputReport(_:)).

  • Step 1: Read the current call shape

bash
sed -n '316,325p' Mos/LogitechHID/LogitechDeviceSession.swift

Expected: see let data = Array(UnsafeBufferPointer(start: report, count: reportLength)) and session.handleInputReport(data).

  • Step 2: Change handleInputReport to take UnsafeBufferPointer<UInt8>

In LogitechDeviceSession.swift, find private func handleInputReport(_ data: [UInt8]) and rename parameter type:

swift
private func handleInputReport(_ data: UnsafeBufferPointer<UInt8>) {
    // body unchanged — already uses indexed access (data[0], data[1]) and data.count
}
  • Step 3: Update the C callback to pass the buffer directly

In the IOHIDReportCallback closure (around line 316), replace:

swift
let data = Array(UnsafeBufferPointer(start: report, count: reportLength))
session.handleInputReport(data)

with:

swift
let buffer = UnsafeBufferPointer(start: report, count: reportLength)
session.handleInputReport(buffer)
  • Step 4: Build and run all existing tests
bash
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build test

Expected: BUILD SUCCEEDED, all tests pass.

  • Step 5: Smoke test on real device

Connect a Logi mouse. Open Mos. Move/click. Open Debug panel. Verify button events arrive. No crash. No memory anomaly.

  • Step 6: Commit
bash
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."

Task 0.2: Post reportingDidComplete on empty-controls path

Files:

  • Modify: Mos/LogitechHID/LogitechDeviceSession.swift:1490-1540 (around advanceReportingQuery / divertBoundControls).

  • Step 1: Read the current branching

bash
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.

  • Step 2: Add the missing post

In the empty-controls branch (the early return that skips sendGetControlReporting), before the return, add:

swift
NotificationCenter.default.post(name: LogitechHIDManager.reportingQueryDidCompleteNotification, object: nil)
LogitechHIDManager.shared.recomputeAndNotifyActivityState()

(The second call mirrors what advanceReportingQuery's normal terminal does.)

  • Step 3: Build
bash
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build

Expected: BUILD SUCCEEDED.

  • Step 4: Add a Tier 2 regression test

Create MosTests/LogiReportingDidCompleteEmptyPathTests.swift:

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)
    }
}
  • Step 5: Run the test
bash
xcodebuild -scheme Debug -destination 'platform=macOS' test -only-testing:MosTests/LogiReportingDidCompleteEmptyPathTests

Expected: PASS.

  • Step 6: Commit
bash
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."

Step 1: Rename Logitech* → Logi*

Mechanical, zero semantic change. Compiler bottle catches missed call sites; canary tests freeze persistence keys against accidental rename.

Task 1.1: Add the persistence canary BEFORE rename (so rename can't break it silently)

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:

swift
#if DEBUG
internal static var featureCacheKeyForTests: String { return featureCacheKey }
#endif
  • Step 2: Expose autosave names

In LogitechHIDDebugPanel.swift, at the top of the class body, add:

swift
#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
  • Step 3: Write the canary test
swift
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.")
    }
}
  • Step 4: Run the canary
bash
xcodebuild -scheme Debug -destination 'platform=macOS' test -only-testing:MosTests/LogiPersistenceCanaryTests

Expected: both tests PASS.

  • Step 5: Commit
bash
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"

Task 1.2: Rename directory + Xcode project references

Files:

  • Move: Mos/LogitechHID/Mos/Logi/

  • Modify: Mos.xcodeproj/project.pbxproj

  • Step 1: Move the directory with git

bash
git mv Mos/LogitechHID Mos/Logi
  • Step 2: Update Xcode group references

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):

bash
sed -i '' 's|LogitechHID/|Logi/|g' Mos.xcodeproj/project.pbxproj
sed -i '' 's|"LogitechHID"|"Logi"|g' Mos.xcodeproj/project.pbxproj
  • Step 3: Build to verify
bash
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build

Expected: BUILD SUCCEEDED. If failures, inspect pbxproj for stale LogitechHID references and fix.

  • Step 4: Commit (dir rename only, no symbol changes yet)
bash
git add Mos.xcodeproj/project.pbxproj Mos/
git commit -m "refactor(logi): rename Mos/LogitechHID to Mos/Logi (dir only)"

Task 1.3: Rename type names Logitech*Logi*

Files: All .swift files inside Mos/Logi/ and any external references.

The eight type renames:

FromTo
LogitechHIDManagerLogiSessionManager
LogitechDeviceSessionLogiDeviceSession
LogitechHIDDebugPanelLogiDebugPanel
LogitechCIDRegistryLogiCIDDirectory
LogitechReceiverRegistryLogiReceiverCatalog
LogitechDivertPlannerLogiDivertPlanner
LogitechConflictDetectorLogiConflictDetector
LogitechHIDButtonEvent (notification string value)LogiButtonEvent

The notification static let identifiers also change:

FromTo
LogitechHIDManager.sessionChangedNotificationLogiSessionManager.sessionChangedNotification
LogitechHIDManager.discoveryStateDidChangeNotificationLogiSessionManager.discoveryStateDidChangeNotification
LogitechHIDManager.reportingQueryDidCompleteNotificationLogiSessionManager.reportingQueryDidCompleteNotification
LogitechHIDManager.activityStateDidChangeNotificationLogiSessionManager.activityStateDidChangeNotification
LogitechHIDManager.buttonEventNotificationLogiSessionManager.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).

  • Step 1: Rename inside Mos/Logi/ first (files renamed via git mv, then symbols)
bash
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.

  • Step 2: Edit each file's class declaration

In each renamed file, change the class FooName declaration:

  • LogiDeviceSession.swift: class LogitechDeviceSessionclass LogiDeviceSession
  • LogiSessionManager.swift: class LogitechHIDManagerclass LogiSessionManager
  • LogiDebugPanel.swift: class LogitechHIDDebugPanelclass LogiDebugPanel
  • LogiCIDDirectory.swift: enum LogitechCIDRegistry (or class) → enum LogiCIDDirectory
  • LogiReceiverCatalog.swift: same pattern
  • DivertPlanner.swift: struct LogitechDivertPlannerstruct LogiDivertPlanner
  • ConflictDetector.swift: enum LogitechConflictDetectorenum LogiConflictDetector

Also:

  • Inside each file's body, replace any Self. chained calls or self-references that hard-code the old name (e.g. comments, log strings).
  • Notification string values:
    • "LogitechHIDSessionChanged""LogiSessionChanged"
    • "LogitechHIDDiscoveryStateDidChange""LogiDiscoveryStateDidChange"
    • "LogitechHIDReportingQueryDidComplete""LogiReportingQueryDidComplete"
    • "LogitechHIDActivityStateDidChange""LogiActivityStateDidChange"
    • "LogitechHIDButtonEvent""LogiButtonEvent"
    • "LogitechHIDDebugLog""LogiDebugLog" (if present)
  • LogitechHIDManager.sharedLogiSessionManager.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):

bash
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:

bash
grep -n '"logitechFeatureCache"' Mos/Logi/LogiDeviceSession.swift
grep -n '"HIDDebug.FeaturesControls.v3"' Mos/Logi/LogiDebugPanel.swift

Both must still be present unchanged.

  • Step 3: Apply the same sed to external call sites
bash
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/:

bash
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
  • Step 4: Update LogiPersistenceCanaryTests to use new type names
swift
// in MosTests/LogiPersistenceCanaryTests.swift
XCTAssertEqual(LogiDeviceSession.featureCacheKeyForTests, "logitechFeatureCache", ...)
XCTAssertEqual(LogiDebugPanel.autosaveNamesSnapshotForTests.sorted(), ...)
  • Step 5: Build
bash
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:

bash
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.)

  • Step 6: Run all tests
bash
xcodebuild -scheme Debug -destination 'platform=macOS' test

Expected: all tests PASS, including the canary.

  • Step 7: Commit
bash
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."

Task 1.4: Rename ScrollCore method handleScrollHotkeyFromHIDPlusPlushandleScrollHotkey

Files:

  • Modify: Mos/ScrollCore/ScrollCore.swift

  • Modify: Mos/Logi/LogiDeviceSession.swift (only caller)

  • Step 1: Read both call sites

bash
grep -n "handleScrollHotkeyFromHIDPlusPlus\|func handleScrollHotkey" Mos/ScrollCore/ScrollCore.swift Mos/Logi/LogiDeviceSession.swift
  • Step 2: Rename the method declaration

In Mos/ScrollCore/ScrollCore.swift find:

swift
func handleScrollHotkeyFromHIDPlusPlus(code: UInt16, isDown: Bool) -> Bool {

Change to:

swift
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.)

  • Step 3: Rename the call sites in LogiDeviceSession
bash
sed -i '' 's/ScrollCore\.shared\.handleScrollHotkeyFromHIDPlusPlus/ScrollCore.shared.handleScrollHotkey/g' Mos/Logi/LogiDeviceSession.swift
  • Step 4: Build
bash
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build

Expected: BUILD SUCCEEDED.

  • Step 5: Commit
bash
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."

Task 1.5: Add LogiCIDDirectoryTests

Files:

  • Create: MosTests/LogiCIDDirectoryTests.swift

  • Step 1: Read CID directory shape

bash
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).

  • Step 2: Write the symmetry test
swift
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
    }
}
  • Step 3: Run
bash
xcodebuild -scheme Debug -destination 'platform=macOS' test -only-testing:MosTests/LogiCIDDirectoryTests

Expected: all PASS.

  • Step 4: Commit
bash
git add MosTests/LogiCIDDirectoryTests.swift
git commit -m "test(logi): add CID directory round-trip and threshold tests"

Task 1.6: Step 1 Codex review × 2

  • Step 1: Run Codex code review on the rename commits
bash
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

bash
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."


Step 2: LogiCenter facade

Introduce the public facade and LogiNoOpBridge. Demote LogiSessionManager to internal. All external call sites switch to LogiCenter.shared.*. UsageRegistry NOT introduced yet — Step 3.

Task 2.1: Define LogiExternalBridge protocol stub + LogiNoOpBridge

Files:

  • Create: Mos/Logi/LogiExternalBridge.swift

  • Create: Mos/Logi/LogiNoOpBridge.swift

  • Step 1: Write protocol stub

swift
// 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
}
  • Step 2: Write NoOp bridge
swift
// 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) {}
}
  • Step 3: Add files to Xcode target

In Xcode, drag the two new files into the Logi group, ensure Mos target checked.

  • Step 4: Build
bash
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build

Expected: BUILD SUCCEEDED. (Nothing references the protocol yet.)

  • Step 5: Commit
bash
git add Mos.xcodeproj/project.pbxproj Mos/Logi/LogiExternalBridge.swift Mos/Logi/LogiNoOpBridge.swift
git commit -m "feat(logi): add LogiExternalBridge protocol + LogiNoOpBridge stubs"

Task 2.2: Create LogiCenter skeleton

Files:

  • Create: Mos/Logi/LogiCenter.swift

  • Step 1: Write the facade

swift
// 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
}
  • Step 2: Define LogiDeviceSessionSnapshot

Add 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):

swift
// 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
    }
}
  • Step 3: Define Direction enum if not already public

Find existing definition:

bash
grep -rn "enum Direction" Mos/Logi/ Mos/Shortcut/

If it lives inside LogiSessionManager, hoist to top-level public:

swift
// At top of LogiSessionManager.swift:
public enum Direction { case up, down }
  • Step 4: Build
bash
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.)

  • Step 5: Commit
bash
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."

Task 2.3: Migrate external call sites — AppDelegate

Files:

  • Modify: Mos/AppDelegate.swift

  • Step 1: Find current call sites

bash
grep -n "LogiSessionManager\.shared" Mos/AppDelegate.swift

Expected: start() and stop().

  • Step 2: Replace
bash
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
  • Step 3: Add bridge install (still NoOp at this step)

In applicationDidFinishLaunching, before the first LogiCenter.shared.start(), add:

swift
LogiCenter.shared.installBridge(LogiNoOpBridge.shared)

(Step 4 will swap to LogiIntegrationBridge.shared and add LogiUsageBootstrap.refreshAll().)

  • Step 4: Build + smoke run
bash
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build

Then run the app from Xcode, verify menu bar status item appears, no crash.

  • Step 5: Commit
bash
git add Mos/AppDelegate.swift
git commit -m "refactor(logi): AppDelegate uses LogiCenter facade"

Task 2.4: Migrate ShortcutExecutor

Files:

  • Modify: Mos/Shortcut/ShortcutExecutor.swift

  • Step 1: Replace

bash
sed -i '' \
  -e 's/LogiSessionManager\.shared\.executeSmartShiftToggle/LogiCenter.shared.executeSmartShiftToggle/g' \
  -e 's/LogiSessionManager\.shared\.executeDPICycle/LogiCenter.shared.executeDPICycle/g' \
  Mos/Shortcut/ShortcutExecutor.swift
  • Step 2: Build
bash
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build

Expected: BUILD SUCCEEDED.

  • Step 3: Commit
bash
git add Mos/Shortcut/ShortcutExecutor.swift
git commit -m "refactor(logi): ShortcutExecutor uses LogiCenter facade"

Task 2.5: Migrate StatusItemManager / PreferencesWindowController / PreferencesButtonsViewController / ButtonTableCellView

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:

swift
LogiDebugPanel.shared.show()

swift
LogiCenter.shared.showDebugPanel()
  • Step 2: PreferencesWindowController

In Mos/Windows/PreferencesWindow/PreferencesWindowController.swift:35:

swift
LogiSessionManager.shared.refreshReportingStatesIfNeeded()

swift
LogiCenter.shared.refreshReportingStatesIfNeeded()
  • Step 3: PreferencesButtonsViewController

Use sed inside this file:

bash
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.)

  • Step 4: ButtonTableCellView
bash
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.)

  • Step 5: Build
bash
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build

Expected: BUILD SUCCEEDED.

  • Step 6: Commit
bash
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)."

Task 2.6: Migrate remaining CID-directory consumers

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

bash
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.)

  • Step 2: Build
bash
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.

  • Step 3: Run all tests
bash
xcodebuild -scheme Debug -destination 'platform=macOS' test

Expected: all PASS.

  • Step 4: Commit
bash
git add -A
git commit -m "refactor(logi): migrate remaining CID directory consumers to facade"

Task 2.7: Demote LogiSessionManager to internal

Files:

  • Modify: Mos/Logi/LogiSessionManager.swift

  • Step 1: Find access modifier

bash
grep -n "^class LogiSessionManager\|^public class LogiSessionManager\|^internal class LogiSessionManager" Mos/Logi/LogiSessionManager.swift
  • Step 2: Add or change to internal

If declared as class LogiSessionManager (default = internal in same target — already correct, leave as-is). If declared public, change to internal.

  • Step 3: Build
bash
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

bash
git add -A
git commit -m "refactor(logi): demote LogiSessionManager to internal access"

Task 2.8: Tier 1 — LogiCenterPublicSurfaceTests

Files:

  • Create: MosTests/LogiCenterPublicSurfaceTests.swift

  • Step 1: Write smoke tests for each public method

swift
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)
    }
}
  • Step 2: Run
bash
xcodebuild -scheme Debug -destination 'platform=macOS' test -only-testing:MosTests/LogiCenterPublicSurfaceTests

Expected: PASS.

  • Step 3: Commit
bash
git add MosTests/LogiCenterPublicSurfaceTests.swift
git commit -m "test(logi): add LogiCenter public surface smoke tests"

Task 2.9: Step 2 Codex review × 2

  • Step 1: Round 1 review
bash
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

bash
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

Step 3: UsageRegistry + LogiUsageBootstrap + preference panel migration

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.

Task 3.1: Define UsageSource + ScrollRole

Files:

  • Create: Mos/Logi/UsageSource.swift

  • Step 1: Write enums

swift
// 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
}
  • Step 2: Add to Xcode target. Build.
bash
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build

Expected: BUILD SUCCEEDED.

  • Step 3: Commit
bash
git add Mos.xcodeproj/project.pbxproj Mos/Logi/UsageSource.swift
git commit -m "feat(logi): add UsageSource and ScrollRole enums"

Task 3.2: TDD — UsageRegistry core

Files:

  • Create: Mos/Logi/UsageRegistry.swift

  • Create: MosTests/UsageRegistryTests.swift

  • Step 1: Write the failing test for setUsage idempotent short-circuit

swift
// 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")
    }
}
  • Step 2: Run test, expect FAIL ("type 'UsageRegistry' not found")
bash
xcodebuild -scheme Debug -destination 'platform=macOS' test -only-testing:MosTests/UsageRegistryTests

Expected: build error or test fail because UsageRegistry doesn't exist.

  • Step 3: Implement minimal UsageRegistry
swift
// 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)
    }
}
  • Step 4: Add applyUsage stub on LogiDeviceSession (real impl in Task 3.4)

In Mos/Logi/LogiDeviceSession.swift, add inside the class:

swift
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
}
  • Step 5: Run tests, expect PASS
bash
xcodebuild -scheme Debug -destination 'platform=macOS' test -only-testing:MosTests/UsageRegistryTests

Expected: 3 tests PASS.

  • Step 6: Commit
bash
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"

Task 3.3: Wire UsageRegistry into LogiCenter

Files:

  • Modify: Mos/Logi/LogiCenter.swift

  • Step 1: Add registry field + init wiring

In LogiCenter.swift, add property:

swift
internal let registry: UsageRegistry

Update private init():

swift
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:

swift
#if DEBUG
internal init(manager: LogiSessionManager,
              registry: UsageRegistry,
              bridge: LogiExternalBridge = LogiNoOpBridge.shared) {
    self.manager = manager
    self.registry = registry
    self.externalBridge = bridge
}
#endif
  • Step 2: Add public setUsage / usages methods
swift
func setUsage(source: UsageSource, codes: Set<UInt16>) {
    registry.setUsage(source: source, codes: codes)
}
func usages(of code: UInt16) -> [UsageSource] {
    return registry.usages(of: code)
}
  • Step 3: Build
bash
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build

Expected: BUILD SUCCEEDED.

  • Step 4: Commit
bash
git add Mos/Logi/LogiCenter.swift
git commit -m "feat(logi): expose setUsage / usages on LogiCenter"

Task 3.4: Real applyUsage with MosCode → CID projection

Files:

  • Modify: Mos/Logi/LogiDeviceSession.swift

  • Step 1: Read existing setControlReporting and divertedCIDs

bash
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>.

  • Step 2: Replace stub with real implementation

In LogiDeviceSession.swift, replace the applyUsage(_:) stub with:

swift
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.)

  • Step 3: Add LogiCIDDirectory.toCID if missing
bash
grep -n "static func toCID\|static func cidFor" Mos/Logi/LogiCIDDirectory.swift

If missing, add:

swift
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
}
  • Step 4: Build
bash
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build

Expected: BUILD SUCCEEDED.

  • Step 5: Commit
bash
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."

Task 3.5: Wire prime hooks (6 paths)

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:

swift
private func primeFromRegistry() {
    LogiCenter.shared.registry.primeSession(self)
}
  • Step 2: Add prime call at each of the six hook sites

In LogiDeviceSession.swift, find and modify:

PathAction
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
  • Step 3: Build
bash
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build

Expected: BUILD SUCCEEDED.

  • Step 4: Commit
bash
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)"

Task 3.6: TDD — UsageRegistryEndToEndTests with FakeLogiDeviceSession

Files:

  • Create: MosTests/LogiTestDoubles/FakeLogiDeviceSession.swift

  • Create: MosTests/UsageRegistryEndToEndTests.swift

  • Step 1: Write the fake session

swift
// 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)
    }
}
  • Step 2: Write end-to-end tests
swift
// 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)])
        }
    }
}
  • Step 3: Run, expect PASS
bash
xcodebuild -scheme Debug -destination 'platform=macOS' test -only-testing:MosTests/UsageRegistryEndToEndTests

Expected: all PASS.

  • Step 4: Commit
bash
git add MosTests/LogiTestDoubles/FakeLogiDeviceSession.swift MosTests/UsageRegistryEndToEndTests.swift
git commit -m "test(logi): UsageRegistry end-to-end + FakeLogiDeviceSession"

Task 3.7: Migrate PreferencesButtonsViewController.syncViewWithOptions

Files:

  • Modify: Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift

  • Step 1: Find the current call

bash
grep -n "syncDivertWithBindings\|syncViewWithOptions" Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift
  • Step 2: Replace

In syncViewWithOptions:

swift
// Old:
LogiSessionManager.shared.syncDivertWithBindings()

// New:
let codes = collectButtonBindingCodes()
LogiCenter.shared.setUsage(source: .buttonBinding, codes: codes)

Add helper method on the controller:

swift
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
}
  • Step 3: Build
bash
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build

Expected: BUILD SUCCEEDED.

  • Step 4: Smoke test

Run app. Open Preferences → Buttons. Add a binding. Hit save. Open Debug panel. Verify Dvrt CIDs count reflects.

  • Step 5: Commit
bash
git add Mos/Windows/PreferencesWindow/ButtonsView/PreferencesButtonsViewController.swift
git commit -m "refactor(logi): button panel uses setUsage(.buttonBinding) instead of syncDivertWithBindings"

Task 3.8: Migrate PreferencesScrollingViewController (5 sites)

Files:

  • Modify: Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingViewController.swift

  • Step 1: Find sites

bash
grep -n "syncDivertWithBindings" Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingViewController.swift

Expected: lines 99, 110, 121, 182, 368.

  • Step 2: Add helper for global scroll codes
swift
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]
}
  • Step 3: Replace each call site

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:

swift
// 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.)

  • Step 4: Build + smoke test
bash
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.

  • Step 5: Commit
bash
git add Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingViewController.swift
git commit -m "refactor(logi): global scroll panel uses setUsage(.globalScroll(role))"

Task 3.9: Migrate PreferencesScrollingWithApplicationViewController + PreferencesApplicationViewController

Files:

  • Modify: Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingWithApplicationViewController.swift

  • Modify: Mos/Windows/PreferencesWindow/ApplicationView/PreferencesApplicationViewController.swift

  • Step 1: Find sites

bash
grep -n "syncDivertWithBindings" Mos/Windows/PreferencesWindow/ScrollingView/PreferencesScrollingWithApplicationViewController.swift Mos/Windows/PreferencesWindow/ApplicationView/PreferencesApplicationViewController.swift
  • Step 2: Add helper for app scroll codes

In PreferencesApplicationViewController.swift:

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: [])
    }
}
  • Step 3: Wire into save / inherit toggle / delete paths

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).

  • Step 4: Build + smoke test
bash
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.

  • Step 5: Commit
bash
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)."

Task 3.10: Implement LogiUsageBootstrap

Files:

  • Create: Mos/Integration/LogiUsageBootstrap.swift

  • Modify: Mos/AppDelegate.swift

  • Create: MosTests/LogiUsageBootstrapTests.swift

  • Step 1: Write LogiUsageBootstrap

swift
// 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]
    }
}
  • Step 2: Wire into AppDelegate launch order

In applicationDidFinishLaunching (and the second start path in startWithAccessibilityPermissionsChecker):

Before LogiCenter.shared.start():

swift
LogiCenter.shared.installBridge(LogiNoOpBridge.shared)  // Step 4 will swap to Integration bridge
LogiUsageBootstrap.refreshAll()
LogiCenter.shared.start()
  • Step 3: Test bootstrap
swift
// 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)
    }
}
  • Step 4: Run all Step 3 tests
bash
xcodebuild -scheme Debug -destination 'platform=macOS' test -only-testing:MosTests/UsageRegistryTests -only-testing:MosTests/UsageRegistryEndToEndTests -only-testing:MosTests/LogiUsageBootstrapTests

Expected: all PASS.

  • Step 5: Commit
bash
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."

Task 3.11: Delete syncDivertWithBindings + collectBoundLogiMosCodes

Files:

  • Modify: Mos/Logi/LogiSessionManager.swift

  • Modify: Mos/Logi/LogiDeviceSession.swift

  • Step 1: Confirm no remaining callers

bash
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.

  • Step 2: Delete LogiSessionManager.syncDivertWithBindings()
bash
# manually edit LogiSessionManager.swift, remove the method
  • Step 3: Delete LogiDeviceSession.syncDivertWithBindings() and collectBoundLogiMosCodes()

The session-level syncDivertWithBindings() is no longer the integration point — applyUsage(_:) is. Remove both methods.

  • Step 4: Build
bash
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build

Expected: BUILD SUCCEEDED. If failures, a caller was missed.

  • Step 5: Run all tests + smoke real device
bash
xcodebuild -scheme Debug -destination 'platform=macOS' test

Expected: all PASS. Then run app, attach Logi mouse, change a binding, verify divert behavior.

  • Step 6: Commit
bash
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."

Task 3.12: Refactor refreshReportingStatesIfNeeded

Files:

  • Modify: Mos/Logi/LogiSessionManager.swift

  • Step 1: Read current implementation

bash
grep -n "func refreshReportingStatesIfNeeded\|hasAnyLogitechBinding" Mos/Logi/LogiSessionManager.swift
  • Step 2: Replace Options scan with registry check

Old (paraphrased):

swift
func refreshReportingStatesIfNeeded() {
    let hasAny = ... // scan Options.buttons, Options.scroll, Options.application
    if !hasAny { return }
    // throttle ... do the actual GetControlReporting on each session
}

New:

swift
func refreshReportingStatesIfNeeded() {
    if LogiCenter.shared.registry.aggregatedCacheIsEmpty { return }
    // throttle ... existing GetControlReporting trigger
}
  • Step 3: Build + commit
bash
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."

Task 3.13: Migrate KeyRecorder

Files:

  • Modify: Mos/Keys/KeyRecorder.swift

  • Step 1: Replace recording entry/exit

bash
sed -i '' \
  -e 's/LogiSessionManager\.shared\.temporarilyDivertAll()/LogiCenter.shared.beginKeyRecording()/g' \
  -e 's/LogiSessionManager\.shared\.restoreDivertToBindings()/LogiCenter.shared.endKeyRecording()/g' \
  Mos/Keys/KeyRecorder.swift
  • Step 2: Replace literal notification name

In KeyRecorder.swift:211:

swift
forName: NSNotification.Name("LogitechHIDButtonEvent"),

swift
forName: LogiCenter.buttonEventRelay,
  • Step 3: Build + commit
bash
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"

Task 3.14: Tier 3a — LogiCenterDeviceIntegrationTests (real device)

Files:

  • Create: MosTests/LogiCenterDeviceIntegrationTests.swift

  • Modify: MosTests/Debug.xctestplan (and add DebugWithDevice.xctestplan)

  • Step 1: Write the gate base + integration test

swift
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
    }
}
  • Step 2: Add discoveredControls to snapshot if missing

In LogiDeviceSessionSnapshot:

swift
public let discoveredControls: [LogiDeviceSession.ControlInfo]

Update init:

swift
internal init(session: LogiDeviceSession) {
    self.connectionMode = session.connectionMode
    self.deviceInfo = session.deviceInfo
    self.pairedDevices = session.debugReceiverPairedDevices
    self.discoveredControls = session.debugDiscoveredControls
}

Then update test helper:

swift
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
}
  • Step 3: Add a new xctestplan that includes this test gated by env

Copy MosTests/Debug.xctestplanMosTests/DebugWithDevice.xctestplan and ensure the latter sets LOGI_REAL_DEVICE=1 in the environment.

  • Step 4: With real device attached, run
bash
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.

  • Step 5: Commit
bash
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"

Task 3.15: Step 3 Codex review × 2

bash
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.


Step 4: Bridge inversion

LogiExternalBridge filled out, LogiIntegrationBridge provides production routing, LogiDeviceSession.dispatchButtonEvent rewritten. After this step, Mos/Logi/ no longer imports ScrollCore / ButtonUtils / InputProcessor / Toast.

Task 4.1: Add LogiCenter.rawButtonEvent + dispatchButtonEvent rewrite

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:

swift
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
    }
}
  • Step 2: Add helper for .up invariant on state-reset paths

Add private method:

swift
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.)

  • Step 3: Build (will fail temporarily — protocol body not yet filled)

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.

bash
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build

If build fails, audit protocol declarations match call sites.

  • Step 4: Commit
bash
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."

Task 4.2: Implement LogiIntegrationBridge

Files:

  • Create: Mos/Integration/LogiIntegrationBridge.swift

  • Step 1: Write the production bridge

swift
// 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)
    }
}
  • Step 2: Migrate session's showFeatureNotAvailable to bridge

In LogiDeviceSession.swift find showFeatureNotAvailable (around line 1197 currently calling Toast.show(message, style: .warning)):

swift
private func showFeatureNotAvailable(_ message: String) {
    LogiCenter.shared.externalBridge.showLogiToast(message, severity: .warning)
}
  • Step 3: Remove imports from Mos/Logi/

Audit:

bash
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:

bash
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.

  • Step 4: Update AppDelegate to install IntegrationBridge

In applicationDidFinishLaunching:

swift
// Before:
LogiCenter.shared.installBridge(LogiNoOpBridge.shared)

// After:
LogiCenter.shared.installBridge(LogiIntegrationBridge.shared)
  • Step 5: Build + smoke test
bash
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.

  • Step 6: Commit
bash
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."

Task 4.3: Tier 2 — LogiBridgeDispatchTests + LogiTeardownTests

Files:

  • Create: MosTests/LogiTestDoubles/FakeLogiExternalBridge.swift

  • Create: MosTests/LogiBridgeDispatchTests.swift

  • Create: MosTests/LogiTeardownTests.swift

  • Step 1: Write fake bridge

swift
// 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))
    }
}
  • Step 2: Write LogiBridgeDispatchTests

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:

swift
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.)

  • Step 3: Write LogiTeardownTests covering 4 paths
swift
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.)

  • Step 4: Build + run
bash
xcodebuild -scheme Debug -destination 'platform=macOS' test

Expected: all PASS.

  • Step 5: Commit
bash
git add MosTests/LogiTestDoubles/FakeLogiExternalBridge.swift MosTests/LogiBridgeDispatchTests.swift MosTests/LogiTeardownTests.swift
git commit -m "test(logi): bridge dispatch + teardown harness scaffolding"

Task 4.4: Tier 3a/b — LogiBridgeDeviceTests + LogiFeatureActionDeviceTests

Files:

  • Create: MosTests/LogiBridgeDeviceTests.swift

  • Create: MosTests/LogiFeatureActionDeviceTests.swift

  • Step 1: Bridge end-to-end

swift
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.)

  • Step 2: Feature action
swift
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.)

  • Step 3: Run with device
bash
LOGI_REAL_DEVICE=1 xcodebuild -scheme Debug -testPlan DebugWithDevice -destination 'platform=macOS' test
  • Step 4: Commit
bash
git add MosTests/LogiBridgeDeviceTests.swift MosTests/LogiFeatureActionDeviceTests.swift
git commit -m "test(logi): real-device bridge round-trip + feature action"

Task 4.5: Step 4 Codex review × 2

bash
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.


Step 5: Subdirectory tidy + ConflictDetector update + Self-Test Wizard + lint

Task 5.1: Reorganize Mos/Logi/ into subdirectories

Files:

  • Move within Mos/Logi/.

  • Step 1: Create subdirs and move

bash
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 ../..
  • Step 2: Update Xcode project group structure

Open Mos.xcodeproj in Xcode. Reorganize the file groups under Logi to match the folder layout. Adjust pbxproj as needed.

  • Step 3: Build
bash
xcodebuild -scheme Debug -configuration Debug -destination 'platform=macOS' build

Expected: BUILD SUCCEEDED.

  • Step 4: Commit
bash
git add -A
git commit -m "refactor(logi): organize Mos/Logi/ into Core/Usage/Divert/Bridge/Debug subdirs"

Task 5.2: ConflictDetector 5-state migration

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

swift
// 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
    }
}
  • Step 2: Update LogiDebugPanel Status column to use detector

In LogiDebugPanel.swift (around line 2089–2122), replace inline boolean computation with:

swift
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
    }
  • Step 3: Migrate ButtonTableCellView ==.conflict → .isConflict
bash
sed -i '' 's/status == \.conflict/status.isConflict/g' Mos/Windows/PreferencesWindow/ButtonsView/ButtonTableCellView.swift

Verify:

bash
grep -n "status\." Mos/Windows/PreferencesWindow/ButtonsView/ButtonTableCellView.swift
  • Step 4: Write 5-state tests
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)
    }
}
  • Step 5: Build + run
bash
xcodebuild -scheme Debug -destination 'platform=macOS' test -only-testing:MosTests/LogiConflictDetectorTests

Expected: all PASS.

  • Step 6: Commit
bash
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."

Task 5.3: CI lint script + boundary enforcement test

Files:

  • Create: scripts/lint-logi-boundary.sh

  • Create: MosTests/LogiBoundaryEnforcementTests.swift

  • Step 1: Write lint script

bash
#!/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."
bash
chmod +x scripts/lint-logi-boundary.sh
  • Step 2: Run it manually
bash
./scripts/lint-logi-boundary.sh

Expected: PASS.

  • Step 3: Add a test that runs the script
swift
// 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 }
}
  • Step 4: Run + commit
bash
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"

Task 5.4: Self-Test Wizard

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

swift
// 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
  • Step 2: Write LogiSelfTestWizard UI
swift
// 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.)

  • Step 3: Add menu item (DEBUG only)

In StatusItemManager.swift:

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
  • Step 4: Build + manual smoke
bash
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.

  • Step 5: Commit
bash
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."

Task 5.5: Step 5 Codex review × 2

bash
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.


Final acceptance check

After all 6 steps land:

  • Run ./scripts/lint-logi-boundary.sh — must pass.
  • Run xcodebuild -scheme Debug -destination 'platform=macOS' test — Tier 1 + Tier 2 all green.
  • Run LOGI_REAL_DEVICE=1 xcodebuild -scheme Debug -testPlan DebugWithDevice -destination 'platform=macOS' test — Tier 3 green with device.
  • Run Bolt suite of Self-Test Wizard with real Bolt receiver — 14/14 pass.
  • Run BLE suite of Self-Test Wizard with real BLE peripheral — all pass.
  • Verify pre-refactor UserDefaults["logitechFeatureCache"] still loads (smoke: connect a device, verify Debug panel shows feature index without re-discovery).
  • Verify AppDelegate launch order: installBridge(LogiIntegrationBridge.shared)LogiUsageBootstrap.refreshAll()LogiCenter.shared.start().
  • Confirm zero references to Logitech* symbols outside Mos/Logi/ and Mos/Integration/ (excluding comments mentioning the third-party "Logitech Options+" app).