diff --git a/ARM64_MIGRATION.md b/ARM64_MIGRATION.md new file mode 100644 index 0000000..198066c --- /dev/null +++ b/ARM64_MIGRATION.md @@ -0,0 +1,224 @@ +# ARM64 Migration Guide + +## Overview + +This document describes the changes made to rebuild MultiSoundChanger for ARM64 (Apple Silicon) architecture. + +## Changes Made + +### 1. Removed x86_64-only OSD.framework Dependency + +**Problem**: The original app depended on OSD.framework, which was compiled only for x86_64 architecture and blocked ARM64 compilation. + +**Solution**: Created a native Swift replacement (`NativeOSDManager.swift`) that provides the same OSD (On-Screen Display) functionality using native macOS APIs. + +**Files Changed**: +- **MultiSoundChanger/Sources/Frameworks/NativeOSDManager.swift** (NEW) + - Native Swift implementation of OSD display + - Uses NSWindow and custom drawing for volume indicator + - Fully compatible with both x86_64 and ARM64 + - Supports speaker and muted speaker icons + - Animated fade-in/fade-out effects + +- **MultiSoundChanger/Other/MultiSoundChanger-Bridging-Header.h** (MODIFIED) + - Removed `#import ` + - Added comment explaining the change + +### 2. Updated Xcode Project Configuration + +**Files Changed**: +- **MultiSoundChanger.xcodeproj/project.pbxproj** (MODIFIED) + - Removed `EXCLUDED_ARCHS = arm64` from Debug configuration + - Removed `EXCLUDED_ARCHS = arm64` from Release configuration + - Removed all references to OSD.framework: + - PBXBuildFile section + - PBXFileReference section + - PBXFrameworksBuildPhase section + - Frameworks group + - Added NativeOSDManager.swift to project: + - PBXBuildFile section + - PBXFileReference section + - Frameworks group + - Sources build phase + +### 3. Dependencies Status + +**MediaKeyTap**: The app uses a custom fork of MediaKeyTap from `https://github.com/the0neyouseek/MediaKeyTap.git`. This is a Swift-based framework and should support ARM64, but needs verification during build. + +**SwiftLint**: Standard linting tool with ARM64 support. + +## Architecture Support + +After these changes, the app should build as a **Universal Binary** supporting: +- **x86_64** (Intel Macs) +- **ARM64** (Apple Silicon - M1, M2, M3, M4) + +## Building the App + +### Prerequisites +- macOS 11.0 or later (for ARM64 support) +- Xcode 12.0 or later +- CocoaPods + +### Build Instructions + +1. **Install Dependencies** + ```bash + cd /path/to/MultiSoundChangerARM + pod install + ``` + +2. **Open Workspace** + ```bash + open MultiSoundChanger.xcworkspace + ``` + ⚠️ **Important**: Open the `.xcworkspace` file, not the `.xcodeproj` file! + +3. **Build** + - Select your target architecture in Xcode (or leave as "Any Mac" for universal binary) + - Product → Build (⌘B) + +4. **Run** + - Product → Run (⌘R) + +### Command Line Build + +For x86_64: +```bash +xcodebuild -workspace MultiSoundChanger.xcworkspace \ + -scheme MultiSoundChanger \ + -configuration Release \ + -arch x86_64 \ + clean build +``` + +For ARM64: +```bash +xcodebuild -workspace MultiSoundChanger.xcworkspace \ + -scheme MultiSoundChanger \ + -configuration Release \ + -arch arm64 \ + clean build +``` + +For Universal Binary: +```bash +xcodebuild -workspace MultiSoundChanger.xcworkspace \ + -scheme MultiSoundChanger \ + -configuration Release \ + -arch "x86_64 arm64" \ + clean build +``` + +## Testing Checklist + +After building, verify the following functionality on ARM64: + +- [ ] App launches successfully +- [ ] Menu bar icon appears and is responsive +- [ ] Audio device enumeration works +- [ ] Volume control works for standard audio devices +- [ ] Volume control works for aggregate audio devices +- [ ] Media keys (volume up/down/mute) are intercepted correctly +- [ ] **OSD (On-Screen Display) volume indicator appears when volume changes** +- [ ] OSD shows correct speaker icon +- [ ] OSD shows muted speaker icon when muted +- [ ] OSD displays on correct screen in multi-monitor setup +- [ ] OSD chiclets (volume bars) reflect correct volume level +- [ ] No crashes or unexpected behavior +- [ ] Accessibility permissions prompt works correctly + +## OSD Implementation Details + +The new native OSD implementation provides: + +### Features +- Custom NSWindow-based overlay +- Centered on the display where mouse cursor is located +- Semi-transparent black background +- White speaker icon (or muted icon with red X) +- Sound waves animation for non-muted state +- Volume level chiclets (bars) +- Smooth fade-in/fade-out animations +- Appears above all windows (`.statusBar` level) +- Ignores mouse events +- Multi-monitor support + +### Visual Appearance +``` +┌─────────────────────┐ +│ │ +│ 🔊 │ ← Speaker icon (or 🔇 if muted) +│ │ +│ ████████▒▒▒▒▒▒▒▒ │ ← Volume chiclets +│ │ +└─────────────────────┘ +``` + +### Behavior +- Displays for 1 second before fading out +- Shows on the screen containing the mouse cursor +- Animates in (0.2s) and out (0.3s) +- Updates in real-time as volume changes + +## Compatibility Notes + +- **Minimum macOS Version**: 11.0 (Big Sur) — raised from 10.10 to enable ARM64 support +- **Recommended macOS Version**: 11.0 or later +- **Code Signing**: Manual with ad-hoc identity (`CODE_SIGN_IDENTITY = "-"`) for local development. Hardened Runtime enabled (`ENABLE_HARDENED_RUNTIME = YES`) with an empty `MultiSoundChanger/Other/MultiSoundChanger.entitlements` file wired via `CODE_SIGN_ENTITLEMENTS`. No Hardened-Runtime exceptions are needed — the app has no JIT, no dylib injection, no outgoing Apple Events. Switch `CODE_SIGN_IDENTITY` to a Developer ID Application certificate and submit to `notarytool` for distribution. +- **Deployment**: Works on both Intel and Apple Silicon Macs + +## Known Issues / Future Improvements + +1. **OSD Visual Design**: The native OSD is a simplified version of the system OSD. Consider: + - Matching the exact system OSD appearance + - Adding support for other OSD types (brightness, keyboard backlight, etc.) + +2. **MediaKeyTap**: Verify the custom fork supports ARM64. If issues arise: + - Update to the latest version + - Switch to the main MediaKeyTap repository + - Or fork and update the dependency + +3. **Code Signing**: For distribution, proper code signing should be configured + +## Rollback Instructions + +The ARM64 migration began with commit `c767aba` ("Rebuild app for ARM64 +(Apple Silicon) support"). To revert fully to the x86_64-only 1.0.1 +release: + +```bash +git checkout 135f003 # [Release] 1.0.1 — last pre-ARM64 tagged commit +``` + +Or, to keep your branch but reset to the pre-migration parent: + +```bash +git reset --hard c767aba^ +``` + +Note: the on-disk `OSD.framework/` directory has already been removed +from this branch (it was a leftover from the pre-migration state and +was no longer referenced by `project.pbxproj`, the bridging header, or +any source file). Rolling back to a pre-deletion commit will restore it +alongside the rest of the tree. + +## Questions or Issues? + +If you encounter any issues with the ARM64 build: + +1. Ensure you're using Xcode 12.0 or later +2. Verify CocoaPods installed all dependencies correctly +3. Check that you opened `.xcworkspace` not `.xcodeproj` +4. Clean build folder: Product → Clean Build Folder (⌘⇧K) +5. Try removing and reinstalling Pods: + ```bash + rm -rf Pods Podfile.lock + pod install + ``` + +## Credits + +- **Original App**: MultiSoundChanger by Dmitry Medyuho +- **ARM64 Migration**: Converted from x86_64 to universal binary (x86_64 + ARM64) +- **Native OSD Implementation**: Custom Swift/Cocoa implementation replacing OSD.framework diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c060b19 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,96 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +For deep-dive subsystem docs, see `docs/`: + +- **[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)** — subsystem-by-subsystem design: audio HAL wrapper, aggregate device model, debounce pipeline, OSD window, media-key flow, menu UI, logger. **Read this before non-trivial changes.** +- **[`docs/BUILD_AND_SIGNING.md`](docs/BUILD_AND_SIGNING.md)** — Xcode workspace, CocoaPods pinning, Hardened Runtime, entitlements file, distribution posture. +- **[`docs/AUDIT_NOTES.md`](docs/AUDIT_NOTES.md)** — things audit agents keep misflagging as bugs but aren't. **Read this before proposing "fixes" to flagged patterns** — the list exists because each item was raised, triaged, and documented as intentional. + +## Project + +MultiSoundChanger is a macOS menu-bar utility that adjusts output volume — including on **aggregate devices**, which macOS's own volume controller cannot handle. This repo is the ARM64-migrated fork (universal binary; Intel + Apple Silicon). Deployment target: macOS 11.0. See `ARM64_MIGRATION.md` for the full migration history. + +## Build / Run + +**Always open `MultiSoundChanger.xcworkspace`, not `MultiSoundChanger.xcodeproj`.** The app depends on CocoaPods (SwiftLint, MediaKeyTap), so building through the `.xcodeproj` directly will fail. + +```bash +pod install # first-time or after Podfile changes +open MultiSoundChanger.xcworkspace # then ⌘B / ⌘R in Xcode +``` + +Command-line universal build: + +```bash +xcodebuild -workspace MultiSoundChanger.xcworkspace \ + -scheme MultiSoundChanger -configuration Release \ + -arch "x86_64 arm64" clean build +``` + +On this user's machine `xcodebuild` is broken (unrelated `IDESimulatorFoundation` plugin issue). For automated verification use Swift typecheck instead — see `docs/BUILD_AND_SIGNING.md`. + +There is no test target. SwiftLint runs as a build phase (`.swiftlint.yml` uses `whitelist_rules`, not default rules; `Pods/` excluded). + +## Architecture at a glance + +Status-bar-only Cocoa app (no main window). Entry point → dependency graph: + +``` +AppDelegate + └── ApplicationControllerImp (owns the three managers; is AudioManagerDelegate + MediaManagerDelegate) + ├── AudioManagerImpl — selected-device state, debounced volume writes, mute + aggregate fan-out + │ └── AudioImpl — CoreAudio HAL wrapper (AudioObjectGet/SetPropertyData with OSStatus checking) + ├── MediaManagerImpl — MediaKeyTap delegate + OSD trigger + Accessibility permission flow + │ └── OSDManager (inline @objc singleton in NativeOSDManager.swift) + └── StatusBarControllerImpl — NSStatusItem menu, device list (sorted), NSMenuDelegate, VolumeViewController host + └── VolumeViewController (loaded from Volume.storyboard) +``` + +Every class is defined as `protocol Foo` + `final class FooImpl` and injected by its parent. Stick to that pattern when adding components. + +### Key invariants (one-liners — expanded in `docs/ARCHITECTURE.md`) + +- **Aggregate devices**: writes fan out to every sub-device; reads return `.max()` from the *first* output sub-device. Intentionally asymmetric. Preserve. +- **`AudioManagerImpl.setSelectedDeviceVolume`** is debounced (33 ms trailing edge). The getter returns `pendingTargetVolume` when set so rapid-repeat quantization sees user intent, not stale HAL state. +- **OSDManager** is a thread-safe `static let` singleton. `showImage` branches on `Thread.isMainThread` to avoid an unnecessary runloop hop from the hotkey path. +- **Volume quantization**: `Constants.chicletsCount = 16` steps so hardware keys align with the OSD chiclets. +- **MediaKeyTap** is pinned by commit hash in `Podfile` (supply-chain fix — never switch back to a floating branch ref). +- **Log writes** use raw POSIX `open(O_NOFOLLOW, 0o600)` — symlink defense against `~/Library/Caches//app.log` being redirected at a sensitive file. +- **Hardened Runtime on** (both Debug + Release). Entitlements declare only `com.apple.security.cs.disable-library-validation` — required because CocoaPods embeds MediaKeyTap as a dynamic framework and our ad-hoc signing has no team identity for library validation to match. See `docs/BUILD_AND_SIGNING.md`. +- **Storyboard identifiers must match class names exactly** (`Stories.swift` instantiates by `String(describing:)`). + +## Workflow + +- **Active branch**: `claude/rebuild-x86-app-011CV4gXVczxQsxNuHeA9X9o` (targets PR #39 on `rlxone/MultiSoundChanger`). +- **After every round of changes, commit and push to that branch.** Don't batch rounds locally. Push target is `origin` (`solartrans/MultiSoundChangerARM`); the PR against upstream updates automatically. +- **Credentials**: a GitHub fine-grained PAT is stored in the macOS Keychain under `git credential-osxkeychain`. `git push` works without any additional setup. If a push ever fails with "Authentication failed", the token likely expired or lost `Contents: Read and write` permission — tell the user, don't try to work around it. +- If SSH/git tooling ever breaks in the sandboxed shell for unrelated reasons, ask the user to run `! git push origin claude/rebuild-x86-app-011CV4gXVczxQsxNuHeA9X9o` as a fallback rather than leaving commits unpushed. + +## Conventions + +- Swift-only source lives under `MultiSoundChanger/Sources/`; non-code assets and `Constants.swift` under `MultiSoundChanger/Other/`. +- UI is storyboard-based (`Volume.storyboard`, `Main.storyboard`). View controllers are loaded via the `Stories` enum helper, which instantiates by `String(describing: classType)` — storyboard identifiers **must match the class name exactly**. +- All user-visible strings go through `Strings.*` (see `Other/Localization/`). Log/debug strings go through `Constants.InnerMessages`. +- Logging: `Logger.debug / info / warning / error`. Writes to `~/Library/Caches//app.log` via a serial background queue + raw POSIX `open(O_NOFOLLOW, 0o600)`; does NOT block main. See `docs/ARCHITECTURE.md` for the rationale. +- Device names are sanitized (newlines/tabs stripped) before logging — see `AudioManagerImpl.sanitizedForLog`. Audio HAL may return any string a plugin chose. +- `fileprivate` is preferred over `private` for same-file extension access (e.g., the listener methods extension on `AudioImpl`). Avoid leaking internal details past the file. + +## What NOT to do + +Collected from a long audit-fix loop; each item has been flagged multiple times by different agents and each is intentional: + +- Don't "fix" `AudioManagerImpl.readDeviceVolumeFromHAL` returning `nil` on an aggregate with no output sub-device — it's documented design. +- Don't rewrite `StatusBarController`'s `100 / 3 * 2` threshold — it IS the correct two-thirds boundary (`(100/3)*2 = 66.66…`), not a precedence bug. +- Don't add an explicit lock around `Logger.isLogFileRemoved` — all access is already serialized via the `fileWriteQueue`. +- Don't switch OSD positioning from `screen.visibleFrame.midY + height/4` to lower-half; the upper-center placement is intentional. +- Don't revert the `@NSApplicationMain` to `@main` — the app's storyboard entry requires the former on this target configuration. +- Don't re-add `Runner.shell` and the `open -b` path for System Settings; the `x-apple.systempreferences:` URL via `NSWorkspace` replaced it for both subprocess-surface reduction and macOS Ventura+ compatibility. +- Full list: **[`docs/AUDIT_NOTES.md`](docs/AUDIT_NOTES.md)**. + +## When in doubt + +- Need to touch the audio path? Read `docs/ARCHITECTURE.md` § Audio subsystem first. +- Need to change build / signing / pods? Read `docs/BUILD_AND_SIGNING.md`. +- Have a finding from a static analyzer or audit agent and unsure if it's real? Check `docs/AUDIT_NOTES.md` — if it's listed, it's already been triaged and it's not a bug. diff --git a/MultiSoundChanger.xcodeproj/project.pbxproj b/MultiSoundChanger.xcodeproj/project.pbxproj index 75c9711..76ffe68 100644 --- a/MultiSoundChanger.xcodeproj/project.pbxproj +++ b/MultiSoundChanger.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 4743EFAB1E91493B0032F5AA /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4743EFAA1E91493B0032F5AA /* AppDelegate.swift */; }; - 6985C6FE251951F8003C2FDB /* OSD.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6985C6FD251951F8003C2FDB /* OSD.framework */; }; E4FFDC0757FD125F92CC0F62 /* Pods_MultiSoundChanger.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C34C05E9BD81D579A0C4957 /* Pods_MultiSoundChanger.framework */; }; F312C54E25B3741C00205846 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F373D8C02561D24600642274 /* Main.storyboard */; }; F312C55025B3742200205846 /* Volume.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F373D8BC2561D22000642274 /* Volume.storyboard */; }; @@ -19,6 +18,7 @@ F373D8BB2561D21900642274 /* Stories.swift in Sources */ = {isa = PBXBuildFile; fileRef = F373D8BA2561D21900642274 /* Stories.swift */; }; F373D8BF2561D22000642274 /* VolumeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F373D8BD2561D22000642274 /* VolumeViewController.swift */; }; F373D8C62561D2A600642274 /* Audio.swift in Sources */ = {isa = PBXBuildFile; fileRef = F373D8C52561D2A600642274 /* Audio.swift */; }; + F373D8C92561D2A600642275 /* NativeOSDManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F373D8C42561D2A600642275 /* NativeOSDManager.swift */; }; F373D8C82561D2B000642274 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F373D8C72561D2B000642274 /* Extensions.swift */; }; F373D8CD2561D36B00642274 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F373D8CB2561D36B00642274 /* Assets.xcassets */; }; F37C2ECF256AA987001C3D36 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F37C2ECE256AA987001C3D36 /* Localizable.strings */; }; @@ -33,7 +33,6 @@ 4743EFA71E91493B0032F5AA /* MultiSoundChanger.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MultiSoundChanger.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4743EFAA1E91493B0032F5AA /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 4C34C05E9BD81D579A0C4957 /* Pods_MultiSoundChanger.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_MultiSoundChanger.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 6985C6FD251951F8003C2FDB /* OSD.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = OSD.framework; sourceTree = ""; }; 6FD0ED04AFD1CC1242C9B3B3 /* Pods-MultiSoundChanger.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MultiSoundChanger.debug.xcconfig"; path = "Target Support Files/Pods-MultiSoundChanger/Pods-MultiSoundChanger.debug.xcconfig"; sourceTree = ""; }; D184B2CD842B856AFFE7DF7E /* Pods-MultiSoundChanger.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MultiSoundChanger.release.xcconfig"; path = "Target Support Files/Pods-MultiSoundChanger/Pods-MultiSoundChanger.release.xcconfig"; sourceTree = ""; }; F3433FCA25B36E16009AAE86 /* Images.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Images.swift; sourceTree = ""; }; @@ -45,6 +44,7 @@ F373D8BD2561D22000642274 /* VolumeViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VolumeViewController.swift; sourceTree = ""; }; F373D8C12561D24600642274 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; F373D8C52561D2A600642274 /* Audio.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Audio.swift; path = Sources/Frameworks/Audio.swift; sourceTree = ""; }; + F373D8C42561D2A600642275 /* NativeOSDManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = NativeOSDManager.swift; path = Sources/Frameworks/NativeOSDManager.swift; sourceTree = ""; }; F373D8C72561D2B000642274 /* Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Extensions.swift; path = Sources/Extensions/Extensions.swift; sourceTree = ""; }; F373D8CA2561D36B00642274 /* MultiSoundChanger-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "MultiSoundChanger-Bridging-Header.h"; sourceTree = ""; }; F373D8CB2561D36B00642274 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -62,7 +62,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 6985C6FE251951F8003C2FDB /* OSD.framework in Frameworks */, E4FFDC0757FD125F92CC0F62 /* Pods_MultiSoundChanger.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -101,6 +100,7 @@ isa = PBXGroup; children = ( F373D8C52561D2A600642274 /* Audio.swift */, + F373D8C42561D2A600642275 /* NativeOSDManager.swift */, ); name = Frameworks; path = ..; @@ -127,7 +127,6 @@ 83889335DD9089B748A33010 /* Frameworks */ = { isa = PBXGroup; children = ( - 6985C6FD251951F8003C2FDB /* OSD.framework */, 4C34C05E9BD81D579A0C4957 /* Pods_MultiSoundChanger.framework */, ); name = Frameworks; @@ -242,7 +241,7 @@ dependencies = ( ); name = MultiSoundChanger; - productName = DynamicsIllusion; + productName = MultiSoundChanger; productReference = 4743EFA71E91493B0032F5AA /* MultiSoundChanger.app */; productType = "com.apple.product-type.application"; }; @@ -337,6 +336,7 @@ }; F373D8802561638C00642274 /* SwiftLint */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -351,7 +351,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/SwiftLint/swiftlint\"\n"; + shellScript = "if [ \"${CONFIGURATION}\" = \"Release\" ]; then\n exit 0\nfi\n\nif [ -f \"${PODS_ROOT}/SwiftLint/swiftlint\" ]; then\n \"${PODS_ROOT}/SwiftLint/swiftlint\" || true\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -365,6 +365,7 @@ F3433FCB25B36E16009AAE86 /* Images.swift in Sources */, F3925975262F2B8000B7AD62 /* ApplicationController.swift in Sources */, F373D8C62561D2A600642274 /* Audio.swift in Sources */, + F373D8C92561D2A600642275 /* NativeOSDManager.swift in Sources */, F37C2ED1256AAA4C001C3D36 /* Strings.swift in Sources */, F373D8B52561D1A600642274 /* MediaManager.swift in Sources */, F373D8B42561D1A600642274 /* AudioManager.swift in Sources */, @@ -442,7 +443,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.10; + MACOSX_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -497,7 +498,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.10; + MACOSX_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; @@ -510,18 +511,19 @@ baseConfigurationReference = 6FD0ED04AFD1CC1242C9B3B3 /* Pods-MultiSoundChanger.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = MultiSoundChanger/Other/MultiSoundChanger.entitlements; CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = ""; - EXCLUDED_ARCHS = arm64; + ENABLE_HARDENED_RUNTIME = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)", ); INFOPLIST_FILE = MultiSoundChanger/Other/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 10.10; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.rlxone.multisoundchanger; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -536,18 +538,19 @@ baseConfigurationReference = D184B2CD842B856AFFE7DF7E /* Pods-MultiSoundChanger.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = MultiSoundChanger/Other/MultiSoundChanger.entitlements; CODE_SIGN_IDENTITY = "-"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = ""; - EXCLUDED_ARCHS = arm64; + ENABLE_HARDENED_RUNTIME = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)", ); INFOPLIST_FILE = MultiSoundChanger/Other/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 10.10; + MACOSX_DEPLOYMENT_TARGET = 11.0; MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.rlxone.multisoundchanger; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/MultiSoundChanger/Other/Constants.swift b/MultiSoundChanger/Other/Constants.swift index a6ea1b1..3fb4617 100644 --- a/MultiSoundChanger/Other/Constants.swift +++ b/MultiSoundChanger/Other/Constants.swift @@ -13,25 +13,27 @@ enum Constants { static let optionMaxLength = 25 static let muteVolumeLowerbound: Float = 0.001 static let logFilename = "app.log" - + enum AppBundleIdentifier { - static let systemPreferences = "com.apple.systempreferences" static let audioDevices = "com.apple.audio.AudioMIDISetup" } - - enum SystemPreferencesPane { - static let sound = "/System/Library/PreferencePanes/Sound.prefPane" + + // x-apple.systempreferences: URL used to jump straight to the Sound pane in System Settings + // (modern macOS) or System Preferences (pre-Ventura). Replaces the previous shell-out via + // `open -b com.apple.systempreferences /System/Library/PreferencePanes/Sound.prefPane`. + enum SystemSettingsURL { + static let sound = "x-apple.systempreferences:com.apple.preference.sound" } - + enum Notifications { static let accessibility = "com.apple.accessibility.api" } - + enum Keys: String { case empty = "" case q } - + enum InnerMessages { static let accessEnabled = "Access enabled" static let accessDenied = "Access denied" @@ -39,15 +41,15 @@ enum Constants { static let outputDevices = "Output devices" static let bundleIdentifierError = "Can't get bundle identifier" static let controllerIdentifierError = "Wrong controller identifier" - + static func debugDevice(deviceID: String, deviceName: String) -> String { return "id: \(deviceID) | name: \(deviceName)" } - + static func selectDevice(deviceID: String) -> String { return "Select device id: \(deviceID)" } - + static func selectedDeviceVolume(volume: String) -> String { return "Selected device volume: \(volume)" } diff --git a/MultiSoundChanger/Other/Info.plist b/MultiSoundChanger/Other/Info.plist index 8902329..dd07750 100644 --- a/MultiSoundChanger/Other/Info.plist +++ b/MultiSoundChanger/Other/Info.plist @@ -24,6 +24,8 @@ $(MACOSX_DEPLOYMENT_TARGET) LSUIElement + NSAccessibilityUsageDescription + MultiSoundChanger uses Accessibility permission to receive the hardware volume and mute keys (volume-up, volume-down, mute) so it can adjust the selected output device — including aggregate devices that macOS's built-in volume control cannot reach. NSHumanReadableCopyright Copyright © 2017 Dmitry Medyuho. All rights reserved. NSMainStoryboardFile diff --git a/MultiSoundChanger/Other/MultiSoundChanger-Bridging-Header.h b/MultiSoundChanger/Other/MultiSoundChanger-Bridging-Header.h index 6b74749..71167d1 100644 --- a/MultiSoundChanger/Other/MultiSoundChanger-Bridging-Header.h +++ b/MultiSoundChanger/Other/MultiSoundChanger-Bridging-Header.h @@ -8,6 +8,6 @@ #define MultiSoundChanger_Bridging_Header_h #import -#import +// OSD/OSDManager.h removed - using native Swift implementation for ARM64 compatibility #endif /* MultiSoundChanger_Bridging_Header_h */ diff --git a/MultiSoundChanger/Other/MultiSoundChanger.entitlements b/MultiSoundChanger/Other/MultiSoundChanger.entitlements new file mode 100644 index 0000000..c203c4a --- /dev/null +++ b/MultiSoundChanger/Other/MultiSoundChanger.entitlements @@ -0,0 +1,34 @@ + + + + + + com.apple.security.cs.disable-library-validation + + + diff --git a/MultiSoundChanger/Sources/AppDelegate/AppDelegate.swift b/MultiSoundChanger/Sources/AppDelegate/AppDelegate.swift index 7ff7d4d..f9d298d 100644 --- a/MultiSoundChanger/Sources/AppDelegate/AppDelegate.swift +++ b/MultiSoundChanger/Sources/AppDelegate/AppDelegate.swift @@ -11,7 +11,7 @@ import Cocoa @NSApplicationMain class AppDelegate: NSObject, NSApplicationDelegate { private let applicationController: ApplicationController = ApplicationControllerImp() - + func applicationDidFinishLaunching(_ aNotification: Notification) { applicationController.start() } diff --git a/MultiSoundChanger/Sources/Classes/ApplicationController.swift b/MultiSoundChanger/Sources/Classes/ApplicationController.swift index 6498952..fcf6093 100644 --- a/MultiSoundChanger/Sources/Classes/ApplicationController.swift +++ b/MultiSoundChanger/Sources/Classes/ApplicationController.swift @@ -11,7 +11,7 @@ import MediaKeyTap // MARK: - Protocols -protocol ApplicationController: class { +protocol ApplicationController: AnyObject { func start() } @@ -21,13 +21,29 @@ final class ApplicationControllerImp: ApplicationController { private lazy var audioManager: AudioManager = AudioManagerImpl() private lazy var mediaManager: MediaManager = MediaManagerImpl(delegate: self) private lazy var statusBarController: StatusBarController = StatusBarControllerImpl(audioManager: audioManager) - + func start() { + // Wire delegate before createMenu so that any listener callback AudioManagerImpl queues + // during construction (unlikely, but main-thread-queued from a HAL firing in the gap) + // finds a non-nil delegate when it runs. + audioManager.delegate = self statusBarController.createMenu() mediaManager.listenMediaKeyTaps() } } +// MARK: - AudioManagerDelegate + +extension ApplicationControllerImp: AudioManagerDelegate { + func audioManagerDidChangeDevices(_ manager: AudioManager) { + statusBarController.refreshDeviceList() + } + + func audioManagerDidChangeDefaultOutputDevice(_ manager: AudioManager) { + statusBarController.syncDefaultOutputDevice() + } +} + // MARK: - MediaManagerDelegate extension ApplicationControllerImp: MediaManagerDelegate { @@ -35,36 +51,40 @@ extension ApplicationControllerImp: MediaManagerDelegate { guard let selectedDeviceVolume = audioManager.getSelectedDeviceVolume() else { return } - + let volumeStep: Float = 1 / Float(Constants.chicletsCount) var volume: Float = (selectedDeviceVolume / volumeStep).rounded() * volumeStep - + switch mediaKey { case .volumeUp: volume = (volume + volumeStep).clamped(to: 0...1) - audioManager.setSelectedDeviceVolume(masterChannelLevel: volume, leftChannelLevel: volume, rightChannelLevel: volume) - + paintVolumeFeedback(volume) + audioManager.setSelectedDeviceVolume(volume: volume) + case .volumeDown: volume = (volume - volumeStep).clamped(to: 0...1) - audioManager.setSelectedDeviceVolume(masterChannelLevel: volume, leftChannelLevel: volume, rightChannelLevel: volume) - + paintVolumeFeedback(volume) + audioManager.setSelectedDeviceVolume(volume: volume) + case .mute: + // Mute path needs the post-toggle state to choose the OSD glyph, so the HAL write + // has to come first here — unlike volumeUp/Down where we already know the target. audioManager.toggleMute() - if audioManager.isSelectedDeviceMuted() { - volume = 0 - } else { - volume = audioManager.getSelectedDeviceVolume() ?? 0 - } - + volume = audioManager.isMuted ? 0 : (audioManager.getSelectedDeviceVolume() ?? 0) + paintVolumeFeedback(volume) + default: break } - + } + + /// Paint the slider, status-bar icon, and OSD for the given 0…1 volume BEFORE the HAL + /// `setSelectedDeviceVolume` call, so the visual feedback appears immediately instead of + /// waiting for the CoreAudio round-trip (especially multi-sub-device aggregate writes). + private func paintVolumeFeedback(_ volume: Float) { let correctedVolume = volume * 100 - statusBarController.updateVolume(value: correctedVolume) mediaManager.showOSD(volume: correctedVolume, chicletsCount: Constants.chicletsCount) - Logger.debug(Constants.InnerMessages.selectedDeviceVolume(volume: String(correctedVolume))) } } diff --git a/MultiSoundChanger/Sources/Classes/AudioManager.swift b/MultiSoundChanger/Sources/Classes/AudioManager.swift index 9f54308..ebb2992 100644 --- a/MultiSoundChanger/Sources/Classes/AudioManager.swift +++ b/MultiSoundChanger/Sources/Classes/AudioManager.swift @@ -11,108 +11,217 @@ import Foundation // MARK: - Protocols -protocol AudioManager: class { +protocol AudioManagerDelegate: AnyObject { + func audioManagerDidChangeDevices(_ manager: AudioManager) + func audioManagerDidChangeDefaultOutputDevice(_ manager: AudioManager) +} + +protocol AudioManager: AnyObject { func getDefaultOutputDevice() -> AudioDeviceID func getOutputDevices() -> [AudioDeviceID: String]? func selectDevice(deviceID: AudioDeviceID) func getSelectedDeviceVolume() -> Float? - func setSelectedDeviceVolume(masterChannelLevel: Float, leftChannelLevel: Float, rightChannelLevel: Float) - func isSelectedDeviceMuted() -> Bool + func setSelectedDeviceVolume(volume: Float) func toggleMute() - + // Update the app's selected device to track an external default-output change (e.g. the user + // switched output in System Settings) without round-tripping through setOutputDevice — which + // would refire the default-output listener and risk a feedback loop. + func adoptSelectedDevice(deviceID: AudioDeviceID) + var isMuted: Bool { get } + var delegate: AudioManagerDelegate? { get set } } // MARK: - Implementation final class AudioManagerImpl: AudioManager { + weak var delegate: AudioManagerDelegate? + private let audio: Audio = AudioImpl() - private let devices: [AudioDeviceID: String]? + private var devices: [AudioDeviceID: String]? private var selectedDevice: AudioDeviceID? - + private var listenerTokens: [AudioListenerToken] = [] + private var volumeBeforeMute: Float? + + // Coalesce rapid `setSelectedDeviceVolume` calls (media-key repeat, slider drag) into a + // single trailing-edge HAL write. Callers paint UI synchronously using the returned-from- + // `getSelectedDeviceVolume` pending value; the HAL write fires `halApplyDelay` after the + // most recent call. + private var pendingTargetVolume: Float? + private var pendingApplyItem: DispatchWorkItem? + private static let halApplyDelay: TimeInterval = 1.0 / 30.0 + init() { devices = audio.getOutputDevices() + let defaultDevice = audio.getDefaultOutputDevice() + selectedDevice = (defaultDevice != kAudioDeviceUnknown) ? defaultDevice : nil printDevices() + registerListeners() } - + + deinit { + pendingApplyItem?.cancel() + for token in listenerTokens { + audio.removeListener(token) + } + } + func getDefaultOutputDevice() -> AudioDeviceID { return audio.getDefaultOutputDevice() } - + func getOutputDevices() -> [AudioDeviceID: String]? { return devices } - - func isAggregateDevice(deviceID: AudioDeviceID) -> Bool { - return audio.isAggregateDevice(deviceID: deviceID) - } - + func selectDevice(deviceID: AudioDeviceID) { + cancelPendingVolumeApply() selectedDevice = deviceID audio.setOutputDevice(newDeviceID: deviceID) Logger.debug(Constants.InnerMessages.selectDevice(deviceID: String(deviceID))) } - + + func adoptSelectedDevice(deviceID: AudioDeviceID) { + cancelPendingVolumeApply() + selectedDevice = deviceID + Logger.debug(Constants.InnerMessages.selectDevice(deviceID: String(deviceID))) + } + func getSelectedDeviceVolume() -> Float? { - guard let selectedDevice = selectedDevice else { - return nil + // Prefer the most recent user-requested value so media-key quantization at the top of + // `onMediaKeyTap` and slider-drag reads see consistent state across rapid events, even + // when the debounced HAL write for the previous event hasn't fired yet. Falls through + // to a live HAL read when nothing's pending (fresh app launch, post-device-switch, etc.). + if let pending = pendingTargetVolume { + return pending } - - if audio.isAggregateDevice(deviceID: selectedDevice) { - let aggregatedDevices = audio.getAggregateDeviceSubDeviceList(deviceID: selectedDevice) - - for device in aggregatedDevices { - if audio.isOutputDevice(deviceID: device) { - return audio.getDeviceVolume(deviceID: device).max() - } + return readDeviceVolumeFromHAL() + } + + func setSelectedDeviceVolume(volume: Float) { + guard selectedDevice != nil else { + return + } + // Capture the latest target so any still-scheduled work item drops through — and any + // intervening `getSelectedDeviceVolume` sees the new value. + pendingTargetVolume = volume + pendingApplyItem?.cancel() + let work = DispatchWorkItem { [weak self] in + self?.flushPendingVolumeApply() + } + pendingApplyItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + Self.halApplyDelay, execute: work) + } + + func toggleMute() { + // Mute/unmute takes precedence over any pending volume write — otherwise a queued + // volume.apply would fire after the mute and overwrite the mute flag via + // setSelectedDeviceVolume's auto-mute branch. + let intendedVolume = getSelectedDeviceVolume() + cancelPendingVolumeApply() + + if isSelectedDeviceMuted() { + setSelectedDeviceMute(isMute: false) + // Some drivers zero the volume scalar while muted. If we come back to an + // effectively-zero scalar after unmuting, restore the pre-mute volume so the + // user doesn't appear stuck at 0% audio. If the pre-mute volume was itself + // below the auto-mute lowerbound (user deliberately muted silence), leave the + // scalar alone — re-applying 0 here would re-trigger the auto-mute branch in + // applyVolumeToHAL and undo the unmute. + if let pre = volumeBeforeMute, + pre >= Constants.muteVolumeLowerbound, + let current = readDeviceVolumeFromHAL(), + current < Constants.muteVolumeLowerbound { + applyVolumeToHAL(pre) } + volumeBeforeMute = nil } else { - return audio.getDeviceVolume(deviceID: selectedDevice).max() + volumeBeforeMute = intendedVolume + setSelectedDeviceMute(isMute: true) } - - return nil } - - func setSelectedDeviceVolume(masterChannelLevel: Float, leftChannelLevel: Float, rightChannelLevel: Float) { + + var isMuted: Bool { + return isSelectedDeviceMuted() + } + + // MARK: Private + + // Actual HAL writer — called from the debounced work item and from `toggleMute`'s + // unmute-restore path. Not exposed. + private func applyVolumeToHAL(_ volume: Float) { guard let selectedDevice = selectedDevice else { return } - - let isMute = masterChannelLevel < Constants.muteVolumeLowerbound - && leftChannelLevel < Constants.muteVolumeLowerbound - && rightChannelLevel < Constants.muteVolumeLowerbound - + + let isMute = volume < Constants.muteVolumeLowerbound + if audio.isAggregateDevice(deviceID: selectedDevice) { let aggregatedDevices = audio.getAggregateDeviceSubDeviceList(deviceID: selectedDevice) - + for device in aggregatedDevices { audio.setDeviceVolume( deviceID: device, - masterChannelLevel: masterChannelLevel, - leftChannelLevel: leftChannelLevel, - rightChannelLevel: rightChannelLevel + masterChannelLevel: volume, + leftChannelLevel: volume, + rightChannelLevel: volume ) audio.setDeviceMute(deviceID: device, isMute: isMute) } } else { audio.setDeviceVolume( deviceID: selectedDevice, - masterChannelLevel: masterChannelLevel, - leftChannelLevel: leftChannelLevel, - rightChannelLevel: rightChannelLevel + masterChannelLevel: volume, + leftChannelLevel: volume, + rightChannelLevel: volume ) audio.setDeviceMute(deviceID: selectedDevice, isMute: isMute) } } - - func setSelectedDeviceMute(isMute: Bool) { + + private func flushPendingVolumeApply() { + guard let target = pendingTargetVolume else { + return + } + pendingTargetVolume = nil + pendingApplyItem = nil + applyVolumeToHAL(target) + } + + private func cancelPendingVolumeApply() { + pendingApplyItem?.cancel() + pendingApplyItem = nil + pendingTargetVolume = nil + } + + // Unconditional HAL read — bypasses the pending-target cache. Used by mute/unmute so the + // driver-zeroed-scalar check sees the actual device state, not a cached intent. + private func readDeviceVolumeFromHAL() -> Float? { + guard let selectedDevice = selectedDevice else { + return nil + } + + if audio.isAggregateDevice(deviceID: selectedDevice) { + let aggregatedDevices = audio.getAggregateDeviceSubDeviceList(deviceID: selectedDevice) + + for device in aggregatedDevices where audio.isOutputDevice(deviceID: device) { + return audio.getDeviceVolume(deviceID: device).max() + } + } else { + return audio.getDeviceVolume(deviceID: selectedDevice).max() + } + + return nil + } + + private func setSelectedDeviceMute(isMute: Bool) { guard let selectedDevice = selectedDevice else { return } - + if audio.isAggregateDevice(deviceID: selectedDevice) { let aggregatedDevices = audio.getAggregateDeviceSubDeviceList(deviceID: selectedDevice) - + for device in aggregatedDevices { audio.setDeviceMute(deviceID: device, isMute: isMute) } @@ -120,46 +229,68 @@ final class AudioManagerImpl: AudioManager { audio.setDeviceMute(deviceID: selectedDevice, isMute: isMute) } } - - func isSelectedDeviceMuted() -> Bool { + + private func isSelectedDeviceMuted() -> Bool { guard let selectedDevice = selectedDevice else { return false } - + if audio.isAggregateDevice(deviceID: selectedDevice) { let aggregatedDevices = audio.getAggregateDeviceSubDeviceList(deviceID: selectedDevice) - + guard let device = aggregatedDevices.first else { return false } - + return audio.isDeviceMuted(deviceID: device) } else { return audio.isDeviceMuted(deviceID: selectedDevice) } } - - func toggleMute() { - if isSelectedDeviceMuted() { - setSelectedDeviceMute(isMute: false) - let volume = getSelectedDeviceVolume() ?? 0 - setSelectedDeviceVolume(masterChannelLevel: volume, leftChannelLevel: volume, rightChannelLevel: volume) - } else { - setSelectedDeviceMute(isMute: true) - } - } - - var isMuted: Bool { - return isSelectedDeviceMuted() - } - + private func printDevices() { guard let devices = devices else { return } Logger.debug(Constants.InnerMessages.outputDevices) for device in devices { - Logger.debug(Constants.InnerMessages.debugDevice(deviceID: String(device.key), deviceName: device.value)) + // Sanitize the device name before logging: CoreAudio returns whatever string the + // device reports, and a device whose name contains `\n` / `\r` / `\t` could + // otherwise inject fake log lines and confuse downstream log readers. + let sanitized = Self.sanitizedForLog(device.value) + Logger.debug(Constants.InnerMessages.debugDevice(deviceID: String(device.key), deviceName: sanitized)) } } + + private static func sanitizedForLog(_ string: String) -> String { + return string + .replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") + .replacingOccurrences(of: "\t", with: " ") + } + + private func registerListeners() { + if let token = audio.addDevicesListener(onChange: { [weak self] in self?.handleDevicesChanged() }) { + listenerTokens.append(token) + } + if let token = audio.addDefaultOutputDeviceListener(onChange: { [weak self] in self?.handleDefaultOutputChanged() }) { + listenerTokens.append(token) + } + } + + private func handleDevicesChanged() { + devices = audio.getOutputDevices() + // If the currently selected device was removed, fall back to whatever the system default + // points at now — hotkeys and the slider keep working instead of silently no-oping. + if let current = selectedDevice, devices?[current] == nil { + cancelPendingVolumeApply() + let fallback = audio.getDefaultOutputDevice() + selectedDevice = (fallback != kAudioDeviceUnknown) ? fallback : nil + } + delegate?.audioManagerDidChangeDevices(self) + } + + private func handleDefaultOutputChanged() { + delegate?.audioManagerDidChangeDefaultOutputDevice(self) + } } diff --git a/MultiSoundChanger/Sources/Classes/MediaManager.swift b/MultiSoundChanger/Sources/Classes/MediaManager.swift index b3a4799..f4028fb 100644 --- a/MultiSoundChanger/Sources/Classes/MediaManager.swift +++ b/MultiSoundChanger/Sources/Classes/MediaManager.swift @@ -12,11 +12,11 @@ import MediaKeyTap // MARK: - Protocols -protocol MediaManagerDelegate: class { +protocol MediaManagerDelegate: AnyObject { func onMediaKeyTap(mediaKey: MediaKey) } -protocol MediaManager: class { +protocol MediaManager: AnyObject { func listenMediaKeyTaps() func showOSD(volume: Float, chicletsCount: Int) } @@ -26,39 +26,50 @@ protocol MediaManager: class { final class MediaManagerImpl: MediaManager { private weak var delegate: MediaManagerDelegate? private var mediaKeyTap: MediaKeyTap? - + // Debounce handle for `onAccessibilityNotification`. DistributedNotificationCenter is a + // system-wide bus — any local process can post `com.apple.accessibility.api`, which our + // handler responds to by tearing down and recreating the CGEventTap. Coalesce bursts + // so a flood of spoofed notifications can't force us into a restart loop. + private var accessibilityNotificationWork: DispatchWorkItem? + private static let accessibilityNotificationDebounce: TimeInterval = 0.5 + init(delegate: MediaManagerDelegate) { self.delegate = delegate } - + deinit { + // Cancel any still-scheduled accessibility-notification work so a late fire can't + // reference a mid-deallocation self. Mirrors AudioManagerImpl.deinit's handling of + // pendingApplyItem. Safe in practice because our weak-self capture no-ops when + // self is nil, but eliminates the tiny pending work item the runloop would + // otherwise hold for up to `accessibilityNotificationDebounce` seconds. + accessibilityNotificationWork?.cancel() DistributedNotificationCenter.default().removeObserver(self) } - + // MARK: Public - + func listenMediaKeyTaps() { - observeMediaKeyOnAccessibiltiyApiChange() + observeMediaKeyOnAccessibilityApiChange() + acquirePrivileges() startMediaKeyTap() } - + func showOSD(volume: Float, chicletsCount: Int = 16) { - guard let manager = OSDManager.sharedManager() as? OSDManager else { - return - } - + let manager = OSDManager.sharedManager() + let mouseloc: NSPoint = NSEvent.mouseLocation var displayForPoint: CGDirectDisplayID = 0 var count: UInt32 = 0 - + if CGGetDisplaysWithPoint(mouseloc, 1, &displayForPoint, &count) != .success { Logger.warning(Constants.InnerMessages.getDisplayError) displayForPoint = CGMainDisplayID() } - - let image = (volume == 0) ? OSDGraphicSpeakerMuted.rawValue : OSDGraphicSpeaker.rawValue + + let image = (volume == 0) ? OSDGraphic.speakerMuted.rawValue : OSDGraphic.speaker.rawValue let volumeStep: Float = 100 / Float(chicletsCount) - + manager.showImage( Int64(image), onDisplayID: displayForPoint, @@ -69,51 +80,54 @@ final class MediaManagerImpl: MediaManager { locked: false ) } - + // MARK: Private - + private func acquirePrivileges() { let trusted = kAXTrustedCheckOptionPrompt.takeUnretainedValue() let privOptions = [trusted: true] as CFDictionary let accessEnabled = AXIsProcessTrustedWithOptions(privOptions) - + if accessEnabled { Logger.warning(Constants.InnerMessages.accessEnabled) } else { Logger.warning(Constants.InnerMessages.accessDenied) } } - + private func startMediaKeyTap() { - acquirePrivileges() - let keys: [MediaKey] = [ .volumeUp, .volumeDown, .mute ] - + mediaKeyTap?.stop() mediaKeyTap = MediaKeyTap(delegate: self, for: keys, observeBuiltIn: true) mediaKeyTap?.start() } - - private func observeMediaKeyOnAccessibiltiyApiChange() { - let notificaion = NSNotification.Name(rawValue: Constants.Notifications.accessibility) - + + private func observeMediaKeyOnAccessibilityApiChange() { + let notification = NSNotification.Name(rawValue: Constants.Notifications.accessibility) + DistributedNotificationCenter.default().addObserver( self, selector: #selector(onAccessibilityNotification), - name: notificaion, + name: notification, object: nil ) } - + @objc private func onAccessibilityNotification(_ aNotification: Notification) { - DispatchQueue.main.async { [weak self] in + // DistributedNotificationCenter delivers on main; coalesce with a cancellable work + // item so a burst only results in one tap restart. + accessibilityNotificationWork?.cancel() + let work = DispatchWorkItem { [weak self] in self?.startMediaKeyTap() } + accessibilityNotificationWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + Self.accessibilityNotificationDebounce, execute: work) } } diff --git a/MultiSoundChanger/Sources/Classes/StatusBarController.swift b/MultiSoundChanger/Sources/Classes/StatusBarController.swift index a23eb41..f18f788 100644 --- a/MultiSoundChanger/Sources/Classes/StatusBarController.swift +++ b/MultiSoundChanger/Sources/Classes/StatusBarController.swift @@ -11,10 +11,12 @@ import Cocoa // MARK: - Protocols -protocol StatusBarController: class { +protocol StatusBarController: AnyObject { func createMenu() func changeStatusItemImage(value: Float) func updateVolume(value: Float) + func refreshDeviceList() + func syncDefaultOutputDevice() } // MARK: - Extensions @@ -33,27 +35,39 @@ extension StatusBarControllerImpl { // MARK: - Implementation -final class StatusBarControllerImpl: StatusBarController { +final class StatusBarControllerImpl: NSObject, StatusBarController { private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) private let volumeController: VolumeViewController private let audioManager: AudioManager - + private var deviceMenuItems: [NSMenuItem] = [] + private var outputSectionAnchor: NSMenuItem? + private var isMenuOpen = false + private var pendingRefresh = false + init(audioManager: AudioManager) { self.audioManager = audioManager - + self.volumeController = Stories.volume.controller(VolumeViewController.self) + super.init() self.volumeController.audioManager = audioManager self.volumeController.statusBarController = self } - + func createMenu() { if let button = statusItem.button { button.image = Images.volumeImage1 + button.setAccessibilityLabel(Strings.volume) } - + + // Defensive: if createMenu is ever invoked twice, the prior device-item array and anchor + // would otherwise leak references to the stale menu's NSMenuItems. + deviceMenuItems.removeAll() + outputSectionAnchor = nil + let menu = NSMenu() menu.autoenablesItems = false - + menu.delegate = self + let volumeItem = getMenuItem(by: .volume) let sliderItem = getMenuItem(by: .slider) let outputItem = getMenuItem(by: .output) @@ -62,57 +76,135 @@ final class StatusBarControllerImpl: StatusBarController { let audioSetupItem = getMenuItem(by: .audioSetup) let secondSeparatorItem = getMenuItem(by: .separator) let quitItem = getMenuItem(by: .quit) - + + outputSectionAnchor = outputItem + menu.addItem(volumeItem) menu.addItem(sliderItem) menu.addItem(outputItem) - setOutputDeviceList(for: menu) + populateDeviceList(in: menu) menu.addItem(firstSeparatorItem) menu.addItem(soundPreferencesItem) menu.addItem(audioSetupItem) menu.addItem(secondSeparatorItem) menu.addItem(quitItem) - + statusItem.menu = menu } - + func changeStatusItemImage(value: Float) { - if value < 1 { + if value <= 1 { statusItem.button?.image = Images.volumeImage1 - } else if value > 1 && value <= 100 / 3 { + } else if value <= 100 / 3 { statusItem.button?.image = Images.volumeImage2 - } else if value > 100 / 3 && value <= 100 / 3 * 2 { + } else if value <= 100 / 3 * 2 { statusItem.button?.image = Images.volumeImage3 - } else if value > 100 / 3 * 2 && value <= 100 { + } else { statusItem.button?.image = Images.volumeImage4 } } - + func updateVolume(value: Float) { volumeController.updateSliderVolume(volume: value) changeStatusItemImage(value: value) } - + + func refreshDeviceList() { + guard let menu = statusItem.menu else { + return + } + // NSMenu mutation while the user has the menu open can crash AppKit's tracking machinery. + // Defer the refresh until menuDidClose fires. + if isMenuOpen { + pendingRefresh = true + return + } + for item in deviceMenuItems { + menu.removeItem(item) + } + deviceMenuItems.removeAll() + populateDeviceList(in: menu) + } + + func syncDefaultOutputDevice() { + let defaultDevice = audioManager.getDefaultOutputDevice() + guard defaultDevice != kAudioDeviceUnknown else { + return + } + let intTag = Int(defaultDevice) + for item in deviceMenuItems { + item.state = (item.tag == intTag) ? .on : .off + } + adoptDevice(defaultDevice) + } + + private func populateDeviceList(in menu: NSMenu) { + guard let devices = audioManager.getOutputDevices() else { + return + } + let defaultDevice = audioManager.getDefaultOutputDevice() + let sortedDevices = devices.sorted { lhs, rhs in + lhs.value.localizedCaseInsensitiveCompare(rhs.value) == .orderedAscending + } + + // Insert device items immediately after the "Output Device:" header so menu ordering + // stays stable on refresh. + let insertionStart: Int + if let anchor = outputSectionAnchor { + let anchorIndex = menu.index(of: anchor) + guard anchorIndex >= 0 else { + Logger.warning("Output section anchor missing from menu; skipping device list rebuild") + return + } + insertionStart = anchorIndex + 1 + } else { + Logger.warning("Output section anchor not set; skipping device list rebuild") + return + } + + var cursor = insertionStart + for device in sortedDevices { + let item = NSMenuItem( + title: truncate(device.value, length: Constants.optionMaxLength), + action: #selector(menuItemAction), + keyEquivalent: "" + ) + item.target = self + item.tag = Int(device.key) + + if device.key == defaultDevice { + item.state = .on + // Adopt — don't re-set the system default to itself, which can refire the + // kAudioHardwarePropertyDefaultOutputDevice listener during startup. + adoptDevice(defaultDevice) + } + + menu.insertItem(item, at: cursor) + deviceMenuItems.append(item) + cursor += 1 + } + } + private func getMenuItem(by type: MenuItem) -> NSMenuItem { switch type { case .volume: let item = NSMenuItem(title: Strings.volume, action: nil, keyEquivalent: Constants.Keys.empty.rawValue) item.isEnabled = false return item - + case .slider: - let item = NSMenuItem(title: String(), action: nil, keyEquivalent: Constants.Keys.empty.rawValue) + let item = NSMenuItem(title: "", action: nil, keyEquivalent: Constants.Keys.empty.rawValue) item.view = volumeController.view return item - + case .output: let item = NSMenuItem(title: Strings.output, action: nil, keyEquivalent: Constants.Keys.empty.rawValue) item.isEnabled = false return item - + case .separator: return NSMenuItem.separator() - + case .soundPreferences: let item = NSMenuItem( title: Strings.soundPreferences, @@ -121,46 +213,33 @@ final class StatusBarControllerImpl: StatusBarController { ) item.target = self return item - + case .audioSetup: let item = NSMenuItem(title: Strings.audioDevices, action: #selector(menuAudioSetupAction), keyEquivalent: Constants.Keys.empty.rawValue) item.target = self return item - + case .quit: let item = NSMenuItem(title: Strings.quit, action: #selector(menuQuitAction), keyEquivalent: Constants.Keys.q.rawValue) item.target = self return item } } - - private func setOutputDeviceList(for menu: NSMenu) { - guard let devices = audioManager.getOutputDevices() else { - return - } - - let defaultDevice = audioManager.getDefaultOutputDevice() - - for device in devices { - let item = NSMenuItem( - title: truncate(device.value, length: Constants.optionMaxLength), - action: #selector(menuItemAction), - keyEquivalent: String() - ) - item.target = self - item.tag = Int(device.key) - - if device.key == defaultDevice { - item.state = .on - selectDevice(device: defaultDevice) - } - - menu.addItem(item) - } - } - + + // User-initiated device selection: propagate to the system default so audio routes follow. private func selectDevice(device: AudioDeviceID) { audioManager.selectDevice(deviceID: device) + refreshUIForSelectedDevice() + } + + // System-initiated or startup-discovered device: update the app's selected device and UI + // without re-writing the system default, which would refire the default-output listener. + private func adoptDevice(_ device: AudioDeviceID) { + audioManager.adoptSelectedDevice(deviceID: device) + refreshUIForSelectedDevice() + } + + private func refreshUIForSelectedDevice() { guard let volume = audioManager.getSelectedDeviceVolume() else { return } @@ -168,7 +247,7 @@ final class StatusBarControllerImpl: StatusBarController { volumeController.updateSliderVolume(volume: correctedVolume) changeStatusItemImage(value: correctedVolume) } - + private func truncate(_ string: String, length: Int, trailing: String = "…") -> String { if string.count > length { return String(string.prefix(length)) + trailing @@ -176,35 +255,49 @@ final class StatusBarControllerImpl: StatusBarController { return string } } - + @objc private func menuItemAction(sender: NSMenuItem) { - guard let items = statusItem.menu?.items else { - return - } - for item in items { - if item == sender { - item.state = .on - let deviceID = AudioDeviceID(item.tag) - selectDevice(device: deviceID) - } else { - item.state = NSControl.StateValue.off - } + for item in deviceMenuItems { + item.state = (item == sender) ? .on : .off } + selectDevice(device: AudioDeviceID(sender.tag)) } - + @objc private func menuSoundPreferencesAction() { - Runner.shell("open -b \(Constants.AppBundleIdentifier.systemPreferences) \(Constants.SystemPreferencesPane.sound)") + // Open the Sound pane via its documented `x-apple.systempreferences:` URL rather than + // shelling out to `open -b`. No subprocess, no shell interpretation — LaunchServices + // picks the right app (System Settings on Ventura+, System Preferences before that). + guard let url = URL(string: Constants.SystemSettingsURL.sound) else { + return + } + NSWorkspace.shared.open(url) } - + @objc private func menuAudioSetupAction() { - Runner.launchApplication(bundleIndentifier: Constants.AppBundleIdentifier.audioDevices, options: .default) + Runner.launchApplication(bundleIdentifier: Constants.AppBundleIdentifier.audioDevices) } - + @objc private func menuQuitAction() { NSApplication.shared.terminate(self) } } + +// MARK: - NSMenuDelegate + +extension StatusBarControllerImpl: NSMenuDelegate { + func menuWillOpen(_ menu: NSMenu) { + isMenuOpen = true + } + + func menuDidClose(_ menu: NSMenu) { + isMenuOpen = false + if pendingRefresh { + pendingRefresh = false + refreshDeviceList() + } + } +} diff --git a/MultiSoundChanger/Sources/Frameworks/Audio.swift b/MultiSoundChanger/Sources/Frameworks/Audio.swift index 1c245f6..ed146f7 100644 --- a/MultiSoundChanger/Sources/Frameworks/Audio.swift +++ b/MultiSoundChanger/Sources/Frameworks/Audio.swift @@ -7,9 +7,33 @@ // import AudioToolbox -import Cocoa import Foundation +// `kAudioObjectPropertyElementMaster` was renamed to `kAudioObjectPropertyElementMain` in macOS 12; +// both symbols resolve to element 0 and the value is invariant across CoreAudio versions. Using the +// literal keeps the deployment floor at 11.0 without producing a deprecation warning on macOS 12+ SDKs. +private let kAudioPropertyElement: AudioObjectPropertyElement = 0 + +// MARK: - Listener token + +// Opaque handle returned by `addXxxListener` and required for removal so the HAL can match +// the exact block pointer it registered. +final class AudioListenerToken { + fileprivate let objectID: AudioObjectID + // `let` so nothing can mutate the address between `addHardwareListener` and + // `removeListener` — the HAL matches the exact block/address pair that was registered, + // and a mutated address would silently orphan the listener. The removeListener path + // copies into a local `var` for the `inout` call. + fileprivate let address: AudioObjectPropertyAddress + fileprivate let block: AudioObjectPropertyListenerBlock + + fileprivate init(objectID: AudioObjectID, address: AudioObjectPropertyAddress, block: @escaping AudioObjectPropertyListenerBlock) { + self.objectID = objectID + self.address = address + self.block = block + } +} + // MARK: - Protocols protocol Audio { @@ -23,276 +47,404 @@ protocol Audio { func isDeviceMuted(deviceID: AudioDeviceID) -> Bool func getDeviceVolume(deviceID: AudioDeviceID) -> [Float] func getDefaultOutputDevice() -> AudioDeviceID - func getDeviceTransportType(deviceID: AudioDeviceID) -> AudioDevicePropertyID + + // Property listeners — callers receive the `onChange` callback on the main queue. + // Returns `nil` when HAL refuses the registration; callers should treat that as a no-op + // subscription and not store the token. + func addDevicesListener(onChange: @escaping () -> Void) -> AudioListenerToken? + func addDefaultOutputDeviceListener(onChange: @escaping () -> Void) -> AudioListenerToken? + func removeListener(_ token: AudioListenerToken) } // MARK: - Implementation final class AudioImpl: Audio { + private static let logQueue = DispatchQueue(label: "com.multisoundchanger.audio.log") + private static let listenerQueue = DispatchQueue(label: "com.multisoundchanger.audio.listener") + private static var lastLoggedTimes: [String: TimeInterval] = [:] + private static let logCooldown: TimeInterval = 2.0 + private static let maxLoggedKeys = 64 + + // Logs non-noErr statuses with a per-(op, status) 2-second cooldown so a disconnected device + // can't flood the log. Returns `true` when the call succeeded. + @discardableResult + private func check(_ status: OSStatus, _ op: String) -> Bool { + if status == noErr { + return true + } + let key = "\(op):\(status)" + let now = Date().timeIntervalSinceReferenceDate + var shouldLog = false + Self.logQueue.sync { + if let last = Self.lastLoggedTimes[key], now - last < Self.logCooldown { + shouldLog = false + } else { + // Hard cap to keep pathological devices (flap storms, unique-status churn) from + // growing the cooldown dictionary without bound. Clearing loses some cooldown + // memory briefly but is bounded-work and never leaks. + if Self.lastLoggedTimes.count >= Self.maxLoggedKeys { + Self.lastLoggedTimes.removeAll(keepingCapacity: true) + } + Self.lastLoggedTimes[key] = now + shouldLog = true + } + } + if shouldLog { + Logger.warning("CoreAudio \(op) failed: status=\(status)") + } + return false + } + func getOutputDevices() -> [AudioDeviceID: String]? { var result: [AudioDeviceID: String] = [:] let devices = getAllDevices() - + for device in devices where isOutputDevice(deviceID: device) { result[device] = getDeviceName(deviceID: device) } - + return result } - + func isOutputDevice(deviceID: AudioDeviceID) -> Bool { - var propertySize: UInt32 = 256 - + var propertySize: UInt32 = 0 + var propertyAddress = AudioObjectPropertyAddress( mSelector: AudioObjectPropertySelector(kAudioDevicePropertyStreams), mScope: AudioObjectPropertyScope(kAudioDevicePropertyScopeOutput), - mElement: AudioObjectPropertyElement(kAudioObjectPropertyElementMaster)) - - AudioObjectGetPropertyDataSize(deviceID, &propertyAddress, 0, nil, &propertySize) - + mElement: kAudioPropertyElement) + + check( + AudioObjectGetPropertyDataSize(deviceID, &propertyAddress, 0, nil, &propertySize), + "isOutputDevice:GetPropertyDataSize" + ) + return propertySize > 0 } - + func getAggregateDeviceSubDeviceList(deviceID: AudioDeviceID) -> [AudioDeviceID] { let subDevicesCount = getNumberOfSubDevices(deviceID: deviceID) + guard subDevicesCount > 0 else { + return [] + } var subDevices = [AudioDeviceID](repeating: 0, count: Int(subDevicesCount)) - + var propertyAddress = AudioObjectPropertyAddress( mSelector: AudioObjectPropertySelector(kAudioAggregateDevicePropertyActiveSubDeviceList), mScope: AudioObjectPropertyScope(kAudioObjectPropertyScopeGlobal), - mElement: AudioObjectPropertyElement(kAudioObjectPropertyElementMaster)) - - var subDevicesSize = subDevicesCount * UInt32(MemoryLayout.size) - - AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &subDevicesSize, &subDevices) - + mElement: kAudioPropertyElement) + + var subDevicesSize = subDevicesCount * UInt32(MemoryLayout.size) + + guard check( + AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &subDevicesSize, &subDevices), + "getAggregateDeviceSubDeviceList:GetPropertyData" + ) else { + return [] + } + return subDevices } - + func isAggregateDevice(deviceID: AudioDeviceID) -> Bool { let deviceType = getDeviceTransportType(deviceID: deviceID) return deviceType == kAudioDeviceTransportTypeAggregate } - + func isDeviceMuted(deviceID: AudioDeviceID) -> Bool { var mutedValue: UInt32 = 0 var propertySize = UInt32(MemoryLayout.size) - + var propertyAddress = AudioObjectPropertyAddress( mSelector: AudioObjectPropertySelector(kAudioDevicePropertyMute), mScope: AudioObjectPropertyScope(kAudioDevicePropertyScopeOutput), - mElement: AudioObjectPropertyElement(kAudioObjectPropertyElementMaster)) - - let status = AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &propertySize, &mutedValue) - - if status != noErr { + mElement: kAudioPropertyElement) + + guard check( + AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &propertySize, &mutedValue), + "isDeviceMuted:GetPropertyData" + ) else { return false } - + return mutedValue == 1 } - + func setDeviceVolume(deviceID: AudioDeviceID, masterChannelLevel: Float, leftChannelLevel: Float, rightChannelLevel: Float) { var leftLevel = leftChannelLevel - var rigthLevel = rightChannelLevel + var rightLevel = rightChannelLevel var masterLevel = masterChannelLevel - - var masterLevelPropertyAddress = AudioObjectPropertyAddress( - mSelector: AudioObjectPropertySelector(kAudioDevicePropertyVolumeScalar), - mScope: AudioObjectPropertyScope(kAudioDevicePropertyScopeOutput), - mElement: AudioObjectPropertyElement(0) + + var masterLevelPropertyAddress = volumeScalarPropertyAddress(element: 0) + var leftLevelPropertyAddress = volumeScalarPropertyAddress(element: 1) + var rightLevelPropertyAddress = volumeScalarPropertyAddress(element: 2) + + // `kAudioDevicePropertyVolumeScalar` is always a `Float32` per Apple's HAL contract; + // skipping the per-element `AudioObjectGetPropertyDataSize` probe halves the IPC + // round-trip count on the hotkey path (noticeable on aggregate devices with multiple + // sub-devices, where every volume keypress used to issue one probe + one set per element + // per sub-device). + let size = UInt32(MemoryLayout.size) + + check( + AudioObjectSetPropertyData(deviceID, &masterLevelPropertyAddress, 0, nil, size, &masterLevel), + "setDeviceVolume:master:SetPropertyData" ) - - var leftLevelPropertyAddress = AudioObjectPropertyAddress( - mSelector: AudioObjectPropertySelector(kAudioDevicePropertyVolumeScalar), - mScope: AudioObjectPropertyScope(kAudioDevicePropertyScopeOutput), - mElement: AudioObjectPropertyElement(1) + check( + AudioObjectSetPropertyData(deviceID, &leftLevelPropertyAddress, 0, nil, size, &leftLevel), + "setDeviceVolume:left:SetPropertyData" ) - - var rightLevelPropertyAddress = AudioObjectPropertyAddress( - mSelector: AudioObjectPropertySelector(kAudioDevicePropertyVolumeScalar), - mScope: AudioObjectPropertyScope(kAudioDevicePropertyScopeOutput), - mElement: AudioObjectPropertyElement(2) + check( + AudioObjectSetPropertyData(deviceID, &rightLevelPropertyAddress, 0, nil, size, &rightLevel), + "setDeviceVolume:right:SetPropertyData" ) - - var size = UInt32(0) - - AudioObjectGetPropertyDataSize(deviceID, &masterLevelPropertyAddress, 0, nil, &size) - AudioObjectSetPropertyData(deviceID, &masterLevelPropertyAddress, 0, nil, size, &masterLevel) - - AudioObjectGetPropertyDataSize(deviceID, &leftLevelPropertyAddress, 0, nil, &size) - AudioObjectSetPropertyData(deviceID, &leftLevelPropertyAddress, 0, nil, size, &leftLevel) - - AudioObjectGetPropertyDataSize(deviceID, &rightLevelPropertyAddress, 0, nil, &size) - AudioObjectSetPropertyData(deviceID, &rightLevelPropertyAddress, 0, nil, size, &rigthLevel) } - + func setDeviceMute(deviceID: AudioDeviceID, isMute: Bool) { var mutedValue: UInt32 = isMute ? 1 : 0 let propertySize = UInt32(MemoryLayout.size) - + var propertyAddress = AudioObjectPropertyAddress( mSelector: AudioObjectPropertySelector(kAudioDevicePropertyMute), mScope: AudioObjectPropertyScope(kAudioDevicePropertyScopeOutput), - mElement: AudioObjectPropertyElement(kAudioObjectPropertyElementMaster)) - - AudioObjectSetPropertyData(deviceID, &propertyAddress, 0, nil, propertySize, &mutedValue) + mElement: kAudioPropertyElement) + + check( + AudioObjectSetPropertyData(deviceID, &propertyAddress, 0, nil, propertySize, &mutedValue), + "setDeviceMute:SetPropertyData" + ) } - + func setOutputDevice(newDeviceID: AudioDeviceID) { let propertySize = UInt32(MemoryLayout.size) var deviceID = newDeviceID - + var propertyAddress = AudioObjectPropertyAddress( mSelector: AudioObjectPropertySelector(kAudioHardwarePropertyDefaultOutputDevice), mScope: AudioObjectPropertyScope(kAudioObjectPropertyScopeGlobal), - mElement: AudioObjectPropertyElement(kAudioObjectPropertyElementMaster)) - - AudioObjectSetPropertyData(AudioObjectID(kAudioObjectSystemObject), &propertyAddress, 0, nil, propertySize, &deviceID) + mElement: kAudioPropertyElement) + + check( + AudioObjectSetPropertyData(AudioObjectID(kAudioObjectSystemObject), &propertyAddress, 0, nil, propertySize, &deviceID), + "setOutputDevice:SetPropertyData" + ) } - + func getDeviceVolume(deviceID: AudioDeviceID) -> [Float] { var leftLevel = Float32(0) - var rigthLevel = Float32(0) + var rightLevel = Float32(0) var masterLevel = Float32(0) - - var masterLevelPropertyAddress = AudioObjectPropertyAddress( - mSelector: AudioObjectPropertySelector(kAudioDevicePropertyVolumeScalar), - mScope: AudioObjectPropertyScope(kAudioDevicePropertyScopeOutput), - mElement: AudioObjectPropertyElement(0) + + var masterLevelPropertyAddress = volumeScalarPropertyAddress(element: 0) + var leftLevelPropertyAddress = volumeScalarPropertyAddress(element: 1) + var rightLevelPropertyAddress = volumeScalarPropertyAddress(element: 2) + + // Same optimization as setDeviceVolume — the scalar is a known-size `Float32`, so skip + // the `GetPropertyDataSize` probe and halve the IPC round-trips on the read path. + // `ioDataSize` is inout on GetPropertyData — reset before each call. + var size = UInt32(MemoryLayout.size) + + check( + AudioObjectGetPropertyData(deviceID, &masterLevelPropertyAddress, 0, nil, &size, &masterLevel), + "getDeviceVolume:master:GetPropertyData" ) - - var leftLevelPropertyAddress = AudioObjectPropertyAddress( - mSelector: AudioObjectPropertySelector(kAudioDevicePropertyVolumeScalar), - mScope: AudioObjectPropertyScope(kAudioDevicePropertyScopeOutput), - mElement: AudioObjectPropertyElement(1) + size = UInt32(MemoryLayout.size) + check( + AudioObjectGetPropertyData(deviceID, &leftLevelPropertyAddress, 0, nil, &size, &leftLevel), + "getDeviceVolume:left:GetPropertyData" ) - - var rightLevelPropertyAddress = AudioObjectPropertyAddress( - mSelector: AudioObjectPropertySelector(kAudioDevicePropertyVolumeScalar), - mScope: AudioObjectPropertyScope(kAudioDevicePropertyScopeOutput), - mElement: AudioObjectPropertyElement(2) + size = UInt32(MemoryLayout.size) + check( + AudioObjectGetPropertyData(deviceID, &rightLevelPropertyAddress, 0, nil, &size, &rightLevel), + "getDeviceVolume:right:GetPropertyData" ) - - var size = UInt32(0) - - AudioObjectGetPropertyDataSize(deviceID, &masterLevelPropertyAddress, 0, nil, &size) - AudioObjectGetPropertyData(deviceID, &masterLevelPropertyAddress, 0, nil, &size, &masterLevel) - - AudioObjectGetPropertyDataSize(deviceID, &leftLevelPropertyAddress, 0, nil, &size) - AudioObjectGetPropertyData(deviceID, &leftLevelPropertyAddress, 0, nil, &size, &leftLevel) - - AudioObjectGetPropertyDataSize(deviceID, &rightLevelPropertyAddress, 0, nil, &size) - AudioObjectGetPropertyData(deviceID, &rightLevelPropertyAddress, 0, nil, &size, &rigthLevel) - - return [masterLevel, leftLevel, rigthLevel] + + return [masterLevel, leftLevel, rightLevel] } - + func getDefaultOutputDevice() -> AudioDeviceID { var propertySize = UInt32(MemoryLayout.size) var deviceID = kAudioDeviceUnknown - + var propertyAddress = AudioObjectPropertyAddress( mSelector: AudioObjectPropertySelector(kAudioHardwarePropertyDefaultOutputDevice), mScope: AudioObjectPropertyScope(kAudioObjectPropertyScopeGlobal), - mElement: AudioObjectPropertyElement(kAudioObjectPropertyElementMaster)) - - AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &propertyAddress, 0, nil, &propertySize, &deviceID) - + mElement: kAudioPropertyElement) + + check( + AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &propertyAddress, 0, nil, &propertySize, &deviceID), + "getDefaultOutputDevice:GetPropertyData" + ) + return deviceID } - - func getDeviceTransportType(deviceID: AudioDeviceID) -> AudioDevicePropertyID { + + private func getDeviceTransportType(deviceID: AudioDeviceID) -> AudioDevicePropertyID { var deviceTransportType = AudioDevicePropertyID() var propertySize = UInt32(MemoryLayout.size) - + var propertyAddress = AudioObjectPropertyAddress( mSelector: AudioObjectPropertySelector(kAudioDevicePropertyTransportType), mScope: AudioObjectPropertyScope(kAudioObjectPropertyScopeGlobal), - mElement: AudioObjectPropertyElement(kAudioObjectPropertyElementMaster)) - - AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &propertySize, &deviceTransportType) - + mElement: kAudioPropertyElement) + + check( + AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &propertySize, &deviceTransportType), + "getDeviceTransportType:GetPropertyData" + ) + return deviceTransportType } - + + // MARK: Helpers + + private func volumeScalarPropertyAddress(element: AudioObjectPropertyElement) -> AudioObjectPropertyAddress { + return AudioObjectPropertyAddress( + mSelector: AudioObjectPropertySelector(kAudioDevicePropertyVolumeScalar), + mScope: AudioObjectPropertyScope(kAudioDevicePropertyScopeOutput), + mElement: element + ) + } + private func getNumberOfDevices() -> UInt32 { var propertySize: UInt32 = 0 - + var propertyAddress = AudioObjectPropertyAddress( mSelector: AudioObjectPropertySelector(kAudioHardwarePropertyDevices), mScope: AudioObjectPropertyScope(kAudioObjectPropertyScopeGlobal), - mElement: AudioObjectPropertyElement(kAudioObjectPropertyElementMaster)) - - AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &propertyAddress, 0, nil, &propertySize) - + mElement: kAudioPropertyElement) + + check( + AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &propertyAddress, 0, nil, &propertySize), + "getNumberOfDevices:GetPropertyDataSize" + ) + return propertySize / UInt32(MemoryLayout.size) } - + private func getNumberOfSubDevices(deviceID: AudioDeviceID) -> UInt32 { var propertySize: UInt32 = 0 - + var propertyAddress = AudioObjectPropertyAddress( mSelector: AudioObjectPropertySelector(kAudioAggregateDevicePropertyActiveSubDeviceList), mScope: AudioObjectPropertyScope(kAudioObjectPropertyScopeGlobal), - mElement: AudioObjectPropertyElement(kAudioObjectPropertyElementMaster)) - - AudioObjectGetPropertyDataSize(deviceID, &propertyAddress, 0, nil, &propertySize) - + mElement: kAudioPropertyElement) + + check( + AudioObjectGetPropertyDataSize(deviceID, &propertyAddress, 0, nil, &propertySize), + "getNumberOfSubDevices:GetPropertyDataSize" + ) + return propertySize / UInt32(MemoryLayout.size) } - + private func getDeviceName(deviceID: AudioDeviceID) -> String { - var propertySize = UInt32(MemoryLayout.size) - + var propertySize = UInt32(MemoryLayout?>.size) + var propertyAddress = AudioObjectPropertyAddress( mSelector: AudioObjectPropertySelector(kAudioDevicePropertyDeviceNameCFString), mScope: AudioObjectPropertyScope(kAudioObjectPropertyScopeGlobal), - mElement: AudioObjectPropertyElement(kAudioObjectPropertyElementMaster)) - - var result: CFString = "" as CFString - - AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &propertySize, &result) - - return result as String - } - - private func getDeviceType(deviceID: AudioDeviceID) -> String { - var propertyAddress = AudioObjectPropertyAddress( - mSelector: AudioObjectPropertySelector(kAudioDevicePropertyDataSourceNameForIDCFString), - mScope: AudioObjectPropertyScope(kAudioObjectPropertyScopeOutput), - mElement: AudioObjectPropertyElement(kAudioObjectPropertyElementMaster)) - - var sourceID: UInt32 = 0 - var result: CFString = "" as CFString - - var translation = AudioValueTranslation( - mInputData: withUnsafeMutablePointer(to: &sourceID) { pointer in pointer }, - mInputDataSize: UInt32(MemoryLayout.size), - mOutputData: withUnsafeMutablePointer(to: &result) { pointer in pointer }, - mOutputDataSize: UInt32(MemoryLayout.size) - ) - - var propertySize = UInt32(MemoryLayout.size) - - AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &propertySize, &translation) - - return result as String + mElement: kAudioPropertyElement) + + // CoreFoundation types (CFString here) must come back through `Unmanaged` — forming a raw + // pointer to a CFString-typed variable is undefined under ARC, which is what the + // "UnsafeMutableRawPointer to CFString" warning was flagging. + var name: Unmanaged? + + guard check( + AudioObjectGetPropertyData(deviceID, &propertyAddress, 0, nil, &propertySize, &name), + "getDeviceName:GetPropertyData" + ), let name = name else { + return "" + } + + let cfstr = name.takeRetainedValue() + // Cap the bridged Swift-String length at something far beyond any real device name + // (legitimate names are a few tens of chars; 256 UTF-16 units is extremely generous). + // A malicious third-party HAL plugin could otherwise return an arbitrarily large + // CFString here and force us to pay a large-bridge + large-Swift-String allocation on + // every device-list refresh. + let length = CFStringGetLength(cfstr) + let maxChars: CFIndex = 256 + if length > maxChars { + // If the last UTF-16 unit at the cut point is a high surrogate (range + // 0xD800...0xDBFF), backing off by one avoids leaving a dangling lead surrogate + // that bridges to Swift as U+FFFD. + var cut = maxChars + let lastChar = CFStringGetCharacterAtIndex(cfstr, cut - 1) + if (0xD800...0xDBFF).contains(lastChar) { + cut -= 1 + } + let truncated = CFStringCreateWithSubstring(kCFAllocatorDefault, cfstr, CFRange(location: 0, length: cut)) + return (truncated as String?) ?? "" + } + return cfstr as String } - + private func getAllDevices() -> [AudioDeviceID] { let devicesCount = getNumberOfDevices() + guard devicesCount > 0 else { + return [] + } var devices = [AudioDeviceID](repeating: 0, count: Int(devicesCount)) - + var propertyAddress = AudioObjectPropertyAddress( mSelector: AudioObjectPropertySelector(kAudioHardwarePropertyDevices), mScope: AudioObjectPropertyScope(kAudioObjectPropertyScopeGlobal), - mElement: AudioObjectPropertyElement(kAudioObjectPropertyElementMaster)) - - var devicesSize = devicesCount * UInt32(MemoryLayout.size) - - AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &propertyAddress, 0, nil, &devicesSize, &devices) - + mElement: kAudioPropertyElement) + + var devicesSize = devicesCount * UInt32(MemoryLayout.size) + + guard check( + AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &propertyAddress, 0, nil, &devicesSize, &devices), + "getAllDevices:GetPropertyData" + ) else { + return [] + } + return devices } } + +// MARK: - Listeners + +extension AudioImpl { + func addDevicesListener(onChange: @escaping () -> Void) -> AudioListenerToken? { + return addHardwareListener(selector: kAudioHardwarePropertyDevices, op: "addDevicesListener", onChange: onChange) + } + + func addDefaultOutputDeviceListener(onChange: @escaping () -> Void) -> AudioListenerToken? { + return addHardwareListener(selector: kAudioHardwarePropertyDefaultOutputDevice, op: "addDefaultOutputDeviceListener", onChange: onChange) + } + + func removeListener(_ token: AudioListenerToken) { + // Copy the immutable stored address into a local `var` so we can pass it `inout` to + // `AudioObjectRemovePropertyListenerBlock`. The HAL reads the address fields to match + // the registered listener; it doesn't need to mutate them. + var address = token.address + check( + AudioObjectRemovePropertyListenerBlock(token.objectID, &address, Self.listenerQueue, token.block), + "removeListener" + ) + } + + fileprivate func addHardwareListener(selector: AudioObjectPropertySelector, op: String, onChange: @escaping () -> Void) -> AudioListenerToken? { + var address = AudioObjectPropertyAddress( + mSelector: selector, + mScope: AudioObjectPropertyScope(kAudioObjectPropertyScopeGlobal), + mElement: kAudioPropertyElement + ) + let block: AudioObjectPropertyListenerBlock = { _, _ in + DispatchQueue.main.async { + onChange() + } + } + guard check( + AudioObjectAddPropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject), &address, Self.listenerQueue, block), + op + ) else { + return nil + } + return AudioListenerToken(objectID: AudioObjectID(kAudioObjectSystemObject), address: address, block: block) + } +} diff --git a/MultiSoundChanger/Sources/Frameworks/NativeOSDManager.swift b/MultiSoundChanger/Sources/Frameworks/NativeOSDManager.swift new file mode 100644 index 0000000..d2d9d01 --- /dev/null +++ b/MultiSoundChanger/Sources/Frameworks/NativeOSDManager.swift @@ -0,0 +1,341 @@ +// +// NativeOSDManager.swift +// MultiSoundChanger +// +// Native ARM64-compatible replacement for OSD.framework +// + +import Cocoa + +// OSD Graphics enum to match the original framework +@objc +enum OSDGraphic: Int { + case speaker = 3 + case speakerMuted = 4 +} + +// Native OSD Manager implementation using a single reusable NSWindow +@objc +class OSDManager: NSObject { + private static let instance = OSDManager() + private var osdWindow: OSDWindow? + + @objc + static func sharedManager() -> OSDManager { + return instance + } + + private override init() { + super.init() + } + + @objc + func showImage( + _ image: Int64, + onDisplayID displayID: CGDirectDisplayID, + priority: UInt32, + msecUntilFade: UInt32, + filledChiclets: UInt32, + totalChiclets: UInt32, + locked: Bool + ) { + let graphic = OSDGraphic(rawValue: Int(image)) ?? .speaker + let fadeDelay = TimeInterval(msecUntilFade) / 1_000.0 + // The media-key handler already runs on main, so invoking synchronously here avoids a + // full runloop tick of latency between the user pressing a volume key and the OSD + // appearing. Non-main callers still fall through to main.async. + if Thread.isMainThread { + displayOSD( + graphic: graphic, + displayID: displayID, + filledChiclets: Int(filledChiclets), + totalChiclets: Int(totalChiclets), + fadeDelay: fadeDelay + ) + } else { + DispatchQueue.main.async { [weak self] in + self?.displayOSD( + graphic: graphic, + displayID: displayID, + filledChiclets: Int(filledChiclets), + totalChiclets: Int(totalChiclets), + fadeDelay: fadeDelay + ) + } + } + } + + private func displayOSD( + graphic: OSDGraphic, + displayID: CGDirectDisplayID, + filledChiclets: Int, + totalChiclets: Int, + fadeDelay: TimeInterval + ) { + guard let targetScreen = resolveScreen(for: displayID) else { + Logger.warning("OSD: no NSScreen available, skipping show") + return + } + + let window: OSDWindow + if let existing = osdWindow { + window = existing + } else { + window = OSDWindow() + osdWindow = window + } + + window.update( + graphic: graphic, + filledChiclets: filledChiclets, + totalChiclets: totalChiclets, + screen: targetScreen + ) + window.show(fadeAfter: fadeDelay) + } + + private func resolveScreen(for displayID: CGDirectDisplayID) -> NSScreen? { + let matched = NSScreen.screens.first { screen in + guard let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID else { + return false + } + return screenNumber == displayID + } + + if matched == nil { + Logger.warning("OSD: no NSScreen matches displayID=\(displayID); using NSScreen.main") + } + + return matched ?? NSScreen.main + } +} + +// Reusable OSD window — created once and updated in place for each volume event. +private final class OSDWindow: NSWindow { + private static let windowSize = NSSize(width: 200, height: 200) + + private let contentPanel: OSDContentView + private var fadeTimer: Timer? + + init() { + contentPanel = OSDContentView( + graphic: .speaker, + filledChiclets: 0, + totalChiclets: Constants.chicletsCount + ) + + let rect = NSRect(origin: .zero, size: OSDWindow.windowSize) + + super.init( + contentRect: rect, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + + self.isOpaque = false + self.backgroundColor = .clear + self.level = .statusBar + self.ignoresMouseEvents = true + self.hasShadow = false + self.isReleasedWhenClosed = false + self.contentView = contentPanel + self.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] + self.animationBehavior = .utilityWindow + } + + deinit { + cleanup() + } + + func update(graphic: OSDGraphic, filledChiclets: Int, totalChiclets: Int, screen: NSScreen) { + contentPanel.update( + graphic: graphic, + filledChiclets: filledChiclets, + totalChiclets: totalChiclets + ) + repositionOn(screen: screen) + } + + func show(fadeAfter delay: TimeInterval) { + fadeTimer?.invalidate() + fadeTimer = nil + + self.alphaValue = 1.0 + self.orderFrontRegardless() + + fadeTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in + self?.fadeOut() + } + } + + func cleanup() { + fadeTimer?.invalidate() + fadeTimer = nil + self.orderOut(nil) + } + + private func repositionOn(screen: NSScreen) { + let size = OSDWindow.windowSize + // Use visibleFrame so the OSD respects the menu bar / dock instead of potentially + // overlapping either on the primary display. + let frame = screen.visibleFrame + let xPos = frame.midX - size.width / 2 + let yPos = frame.midY + frame.height / 4 - size.height / 2 + self.setFrameOrigin(NSPoint(x: xPos, y: yPos)) + } + + private func fadeOut() { + fadeTimer?.invalidate() + fadeTimer = nil + + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.3 + self.animator().alphaValue = 0 + }, completionHandler: { [weak self] in + self?.orderOut(nil) + }) + } +} + +// Content view that draws the OSD. Values are mutable so the parent window can be reused. +private final class OSDContentView: NSView { + private var graphic: OSDGraphic + private var filledChiclets: Int + private var totalChiclets: Int + + init(graphic: OSDGraphic, filledChiclets: Int, totalChiclets: Int) { + self.graphic = graphic + self.filledChiclets = filledChiclets + self.totalChiclets = totalChiclets + super.init(frame: .zero) + self.wantsLayer = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(graphic: OSDGraphic, filledChiclets: Int, totalChiclets: Int) { + self.graphic = graphic + self.filledChiclets = filledChiclets + self.totalChiclets = totalChiclets + self.needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + let backgroundRect = bounds.insetBy(dx: 20, dy: 20) + let backgroundPath = NSBezierPath(roundedRect: backgroundRect, xRadius: 20, yRadius: 20) + + NSColor.black.withAlphaComponent(0.8).setFill() + backgroundPath.fill() + + drawIcon(in: backgroundRect) + drawChiclets(in: backgroundRect) + } + + private func drawIcon(in rect: NSRect) { + let iconSize: CGFloat = 40 + let iconRect = NSRect( + x: rect.midX - iconSize / 2, + y: rect.maxY - iconSize - 30, + width: iconSize, + height: iconSize + ) + + NSColor.white.setFill() + + if graphic == .speakerMuted { + drawSpeakerShape(in: iconRect) + drawMuteX(in: iconRect) + } else { + drawSpeakerShape(in: iconRect) + drawSoundWaves(in: iconRect) + } + } + + private func drawSpeakerShape(in rect: NSRect) { + let path = NSBezierPath() + + let coneRect = NSRect( + x: rect.minX + rect.width * 0.2, + y: rect.minY + rect.height * 0.3, + width: rect.width * 0.3, + height: rect.height * 0.4 + ) + + path.move(to: NSPoint(x: coneRect.minX, y: coneRect.minY)) + path.line(to: NSPoint(x: coneRect.maxX, y: coneRect.minY + coneRect.height * 0.2)) + path.line(to: NSPoint(x: coneRect.maxX, y: coneRect.maxY - coneRect.height * 0.2)) + path.line(to: NSPoint(x: coneRect.minX, y: coneRect.maxY)) + path.close() + + NSColor.white.setFill() + path.fill() + } + + private func drawSoundWaves(in rect: NSRect) { + let startX = rect.maxX - rect.width * 0.35 + let centerY = rect.midY + + for i in 1...3 { + let arc = NSBezierPath() + let radius = CGFloat(i) * 5 + arc.appendArc( + withCenter: NSPoint(x: startX, y: centerY), + radius: radius, + startAngle: -30, + endAngle: 30 + ) + + NSColor.white.setStroke() + arc.lineWidth = 2 + arc.stroke() + } + } + + private func drawMuteX(in rect: NSRect) { + let xPath = NSBezierPath() + let inset: CGFloat = rect.width * 0.25 + + xPath.move(to: NSPoint(x: rect.minX + inset, y: rect.minY + inset)) + xPath.line(to: NSPoint(x: rect.maxX - inset, y: rect.maxY - inset)) + xPath.move(to: NSPoint(x: rect.maxX - inset, y: rect.minY + inset)) + xPath.line(to: NSPoint(x: rect.minX + inset, y: rect.maxY - inset)) + + NSColor.red.setStroke() + xPath.lineWidth = 3 + xPath.stroke() + } + + private func drawChiclets(in rect: NSRect) { + guard totalChiclets > 0 else { + return + } + + let chicletAreaWidth = rect.width - 60 + let chicletAreaHeight: CGFloat = 8 + let chicletSpacing: CGFloat = 2 + let chicletWidth = (chicletAreaWidth - CGFloat(totalChiclets - 1) * chicletSpacing) / CGFloat(totalChiclets) + + let startX = rect.minX + 30 + let startY = rect.minY + 40 + + for i in 0.. - + diff --git a/MultiSoundChanger/Sources/Stories/Stories.swift b/MultiSoundChanger/Sources/Stories/Stories.swift index 83a0bc3..fdf90d3 100644 --- a/MultiSoundChanger/Sources/Stories/Stories.swift +++ b/MultiSoundChanger/Sources/Stories/Stories.swift @@ -17,12 +17,12 @@ extension Stories { func controller(_ classType: T.Type) -> T { let storyboard = NSStoryboard(name: rawValue, bundle: nil) let identifier = String(describing: classType) - + guard let controller = storyboard.instantiateController(withIdentifier: identifier) as? T else { Logger.error(Constants.InnerMessages.controllerIdentifierError) fatalError(Constants.InnerMessages.controllerIdentifierError) } - + return controller } } diff --git a/MultiSoundChanger/Sources/Stories/Volume/VolumeViewController.swift b/MultiSoundChanger/Sources/Stories/Volume/VolumeViewController.swift index e1861c7..7efed4a 100644 --- a/MultiSoundChanger/Sources/Stories/Volume/VolumeViewController.swift +++ b/MultiSoundChanger/Sources/Stories/Volume/VolumeViewController.swift @@ -6,27 +6,24 @@ // Copyright © 2017 Dmitry Medyuho. All rights reserved. // -import AudioToolbox import Cocoa -import MediaKeyTap final class VolumeViewController: NSViewController { @IBOutlet weak var volumeSlider: NSSlider! - private var muted: Bool = false - + weak var statusBarController: StatusBarController? var audioManager: AudioManager? - - private func changeDeviceVolume(value: Float) { - audioManager?.setSelectedDeviceVolume(masterChannelLevel: value, leftChannelLevel: value, rightChannelLevel: value) - } - + func updateSliderVolume(volume: Float) { volumeSlider.floatValue = volume.clamped(to: 0...100) } - + @IBAction func volumeSliderAction(_ sender: Any) { - changeDeviceVolume(value: volumeSlider.floatValue / 100) - statusBarController?.changeStatusItemImage(value: volumeSlider.floatValue) + let sliderValue = volumeSlider.floatValue + statusBarController?.changeStatusItemImage(value: sliderValue) + // `AudioManager.setSelectedDeviceVolume` internally debounces the HAL write, so it's + // safe to call on every drag event — per-pixel slider motion no longer blocks main on + // CoreAudio IPC. + audioManager?.setSelectedDeviceVolume(volume: sliderValue / 100) } } diff --git a/MultiSoundChanger/Sources/Utils/Logger.swift b/MultiSoundChanger/Sources/Utils/Logger.swift index b234053..f0fe8ac 100644 --- a/MultiSoundChanger/Sources/Utils/Logger.swift +++ b/MultiSoundChanger/Sources/Utils/Logger.swift @@ -15,18 +15,38 @@ enum Logger { case warning = "🟠" case error = "🔴" } - + private enum Symbol: String { case newLine = "\n" } - - private enum LoggerError: Error { + + private enum LoggerError: Error, LocalizedError { case fileError(String) case dataError + + // Surface our message via `localizedDescription` so the outer `filePrint` catch + // (which calls `error.localizedDescription`) actually sees the errno/path telemetry + // instead of Cocoa's generic "The operation couldn't be completed." wrapper. + var errorDescription: String? { + switch self { + case .fileError(let message): + return message + case .dataError: + return "Failed to encode log message as UTF-8" + } + } } - + + // `isLogFileRemoved` is mutable state but is ONLY ever touched from inside the + // `fileWriteQueue.async` block below — all `filePrint` → `removeLogFileIfNeeded` callers + // funnel through that serial queue. A `DispatchQueue(label:)` with no attributes is serial + // by default, so reads and writes here are naturally sequenced without an explicit lock. private static var isLogFileRemoved = false - + // Serialize and offload file I/O so per-keypress logging (AudioManager.selectDevice, + // ApplicationController.onMediaKeyTap) doesn't stall the main thread on FileManager / + // FileHandle syscalls. Serial-by-default — do not pass `.concurrent`. + private static let fileWriteQueue = DispatchQueue(label: "com.multisoundchanger.logger") + private static var bundleIdentifier: String { guard let bundleIdentifier = Bundle.main.bundleIdentifier else { outPrint(symbol: .error, string: Constants.InnerMessages.bundleIdentifierError) @@ -34,43 +54,47 @@ enum Logger { } return bundleIdentifier } - + static func info(_ string: String) { outAndFilePrint(symbol: .info, string: string) } - + static func debug(_ string: String) { outAndFilePrint(symbol: .debug, string: string) } - + static func warning(_ string: String) { outAndFilePrint(symbol: .warning, string: string) } - + static func error(_ string: String) { outAndFilePrint(symbol: .error, string: string) } - + private static func getDebugLine(symbol: DebugSymbol, string: String) -> String { - let symbol = DebugSymbol.info.rawValue let logDate = getLogDate() - return "\(symbol) [\(logDate)] \(string)" + return "\(symbol.rawValue) [\(logDate)] \(string)" } - + private static func outAndFilePrint(symbol: DebugSymbol, string: String) { - outPrint(symbol: .error, string: string) - do { - try filePrint(symbol: .info, string: string) - } catch let error { - outPrint(symbol: .error, string: error.localizedDescription) + outPrint(symbol: symbol, string: string) + fileWriteQueue.async { + do { + try filePrint(symbol: symbol, string: string) + } catch let error { + // Print the file error back on main so it surfaces alongside the stdout stream. + DispatchQueue.main.async { + outPrint(symbol: .error, string: error.localizedDescription) + } + } } } - + private static func outPrint(symbol: DebugSymbol, string: String) { let line = getDebugLine(symbol: symbol, string: string) print(line) } - + private static func filePrint(symbol: DebugSymbol, string: String, filename: String = Constants.logFilename) throws { do { var directoryUrl = try FileManager.default.url( @@ -81,7 +105,7 @@ enum Logger { ) directoryUrl.appendPathComponent(bundleIdentifier) try createDirectoryIfNeeded(url: directoryUrl) - let fileUrl = directoryUrl.appendingPathComponent(Constants.logFilename, isDirectory: false) + let fileUrl = directoryUrl.appendingPathComponent(filename, isDirectory: false) let line = wrapNewLine(getDebugLine(symbol: symbol, string: string)) try removeLogFileIfNeeded(url: fileUrl) try appendToFile(url: fileUrl, content: line) @@ -89,28 +113,61 @@ enum Logger { throw LoggerError.fileError(error.localizedDescription) } } - + private static func appendToFile(url: URL, content: String) throws { - if FileManager.default.fileExists(atPath: url.path) { - let fileHandle = try FileHandle(forWritingTo: url) - guard let data = content.data(using: .utf8) else { - throw LoggerError.dataError + guard let data = content.data(using: .utf8) else { + throw LoggerError.dataError + } + // Raw POSIX open with O_NOFOLLOW defends against a local attacker planting a symlink + // at our log path (~/Library/Caches//app.log) pointing at e.g. ~/.ssh/id_rsa, + // which a naive FileHandle(forWritingTo:) would follow and end up appending log lines + // into the symlink's target. O_NOFOLLOW makes open() fail with ELOOP instead. + // Mode 0600 on creation keeps the log file user-only. + let flags = O_WRONLY | O_APPEND | O_CREAT | O_NOFOLLOW + let fd = url.path.withCString { path in + Darwin.open(path, flags, mode_t(0o600)) + } + guard fd >= 0 else { + let reason = String(cString: strerror(errno)) + throw LoggerError.fileError("open(\(url.path)) failed: \(reason) (errno=\(errno))") + } + defer { Darwin.close(fd) } + + // Loop until the whole buffer is flushed. A single `Darwin.write` can return fewer + // bytes than requested on EINTR, disk pressure, or signal interruption — the old + // single-shot call silently dropped the tail in those cases. + let writeError: String? = data.withUnsafeBytes { buffer -> String? in + guard let base = buffer.baseAddress else { + return "empty write buffer" + } + var offset = 0 + while offset < buffer.count { + let written = Darwin.write(fd, base.advanced(by: offset), buffer.count - offset) + if written < 0 { + if errno == EINTR { + continue + } + return "\(String(cString: strerror(errno))) (errno=\(errno)) after \(offset)/\(buffer.count) bytes" + } + if written == 0 { + return "write returned 0 after \(offset)/\(buffer.count) bytes" + } + offset += written } - fileHandle.seekToEndOfFile() - fileHandle.write(data) - fileHandle.closeFile() - } else { - try content.write(to: url, atomically: true, encoding: .utf8) + return nil + } + if let writeError = writeError { + throw LoggerError.fileError("write(\(url.path)) failed: \(writeError)") } } - + private static func createDirectoryIfNeeded(url: URL) throws { guard !FileManager.default.fileExists(atPath: url.path) else { return } try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false, attributes: nil) } - + private static func removeLogFileIfNeeded(url: URL) throws { guard !isLogFileRemoved else { return @@ -121,16 +178,23 @@ enum Logger { } try FileManager.default.removeItem(at: url) } - + private static func wrapNewLine(_ string: String) -> String { return string + Symbol.newLine.rawValue } - - private static func getLogDate() -> String { - let date = Date() + + // Cached — DateFormatter construction is ~orders of magnitude more expensive than + // `.string(from:)`, and `getLogDate()` runs on every log line. The formatter itself is + // thread-safe for reads per Apple's docs, and we only ever read-call `.string(from:)` on it + // after initialization. + private static let logDateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateStyle = .short formatter.timeStyle = .medium - return formatter.string(from: date) + return formatter + }() + + private static func getLogDate() -> String { + return logDateFormatter.string(from: Date()) } } diff --git a/MultiSoundChanger/Sources/Utils/Runner.swift b/MultiSoundChanger/Sources/Utils/Runner.swift index 2bf1fda..88a2f95 100644 --- a/MultiSoundChanger/Sources/Utils/Runner.swift +++ b/MultiSoundChanger/Sources/Utils/Runner.swift @@ -9,32 +9,13 @@ import Cocoa enum Runner { - @discardableResult - static func shell(_ command: String) -> String? { - let task = Process() - let pipe = Pipe() - - task.standardOutput = pipe - task.standardError = pipe - task.arguments = ["-c", command] - task.launchPath = "/bin/sh" - task.launch() - - let data = pipe.fileHandleForReading.readDataToEndOfFile() - - guard let output = String(data: data, encoding: .utf8) else { - return nil + static func launchApplication(bundleIdentifier: String) { + // Modern replacement for `NSWorkspace.launchApplication(withBundleIdentifier:options:...)`, + // which was deprecated in macOS 11. Resolve bundle ID → app URL, then open with a + // default `NSWorkspaceOpenConfiguration`. Silently no-ops if the app isn't installed. + guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) else { + return } - - return output - } - - static func launchApplication(bundleIndentifier: String, options: NSWorkspace.LaunchOptions) { - NSWorkspace.shared.launchApplication( - withBundleIdentifier: bundleIndentifier, - options: options, - additionalEventParamDescriptor: nil, - launchIdentifier: nil - ) + NSWorkspace.shared.openApplication(at: url, configuration: NSWorkspace.OpenConfiguration(), completionHandler: nil) } } diff --git a/OSD.framework/Headers/OSDManager.h b/OSD.framework/Headers/OSDManager.h deleted file mode 100644 index 255db3f..0000000 --- a/OSD.framework/Headers/OSDManager.h +++ /dev/null @@ -1,37 +0,0 @@ -#import "OSDUIHelperProtocol.h" - -@class NSXPCConnection; - -@interface OSDManager : NSObject -{ - id _proxyObject; - NSXPCConnection *connection; -} - -+ (id)sharedManager; -@property(retain) NSXPCConnection *connection; // @synthesize connection; -- (void)showFullScreenImage:(long long)arg1 onDisplayID:(unsigned int)arg2 priority:(unsigned int)arg3 msecToAnimate:(unsigned int)arg4; -- (void)fadeClassicImageOnDisplay:(unsigned int)arg1; -- (void)showImageAtPath:(id)arg1 onDisplayID:(unsigned int)arg2 priority:(unsigned int)arg3 msecUntilFade:(unsigned int)arg4 withText:(id)arg5; -- (void)showImage:(long long)arg1 onDisplayID:(unsigned int)arg2 priority:(unsigned int)arg3 msecUntilFade:(unsigned int)arg4 filledChiclets:(unsigned int)arg5 totalChiclets:(unsigned int)arg6 locked:(BOOL)arg7; -- (void)showImage:(long long)arg1 onDisplayID:(unsigned int)arg2 priority:(unsigned int)arg3 msecUntilFade:(unsigned int)arg4 withText:(id)arg5; -- (void)showImage:(long long)arg1 onDisplayID:(unsigned int)arg2 priority:(unsigned int)arg3 msecUntilFade:(unsigned int)arg4; -@property(readonly) id remoteObjectProxy; // @dynamic remoteObjectProxy; - -typedef enum { - OSDGraphicBacklight = 1, // 1, 2, 7, 8 - OSDGraphicSpeaker = 3, // 3, 5, 17, 23 - OSDGraphicSpeakerMuted = 4, // 4, 16, 21, 22 - OSDGraphicEject = 6, - OSDGraphicNoWiFi = 9, - OSDGraphicKeyboardBacklightMeter = 11, // 11, 25 - OSDGraphicKeyboardBacklightDisabledMeter = 12, // 12, 26 - OSDGraphicKeyboardBacklightNotConnected = 13, // 13, 27 - OSDGraphicKeyboardBacklightDisabledNotConnected = 14, // 14, 28 - OSDGraphicMacProOpen = 15, - OSDGraphicHotspot = 19, - OSDGraphicSleep = 20, - // There may be more -} OSDGraphic; - -@end diff --git a/OSD.framework/Headers/OSDUIHelperProtocol.h b/OSD.framework/Headers/OSDUIHelperProtocol.h deleted file mode 100644 index a1246c0..0000000 --- a/OSD.framework/Headers/OSDUIHelperProtocol.h +++ /dev/null @@ -1,11 +0,0 @@ -@class NSString; - -@protocol OSDUIHelperProtocol -- (void)showFullScreenImage:(long long)arg1 onDisplayID:(unsigned int)arg2 priority:(unsigned int)arg3 msecToAnimate:(unsigned int)arg4; -- (void)fadeClassicImageOnDisplay:(unsigned int)arg1; -- (void)showImageAtPath:(NSString *)arg1 onDisplayID:(unsigned int)arg2 priority:(unsigned int)arg3 msecUntilFade:(unsigned int)arg4 withText:(NSString *)arg5; -- (void)showImage:(long long)arg1 onDisplayID:(unsigned int)arg2 priority:(unsigned int)arg3 msecUntilFade:(unsigned int)arg4 filledChiclets:(unsigned int)arg5 totalChiclets:(unsigned int)arg6 locked:(BOOL)arg7; -- (void)showImage:(long long)arg1 onDisplayID:(unsigned int)arg2 priority:(unsigned int)arg3 msecUntilFade:(unsigned int)arg4 withText:(NSString *)arg5; -- (void)showImage:(long long)arg1 onDisplayID:(unsigned int)arg2 priority:(unsigned int)arg3 msecUntilFade:(unsigned int)arg4; -@end - diff --git a/OSD.framework/OSD b/OSD.framework/OSD deleted file mode 120000 index ee6bfa0..0000000 --- a/OSD.framework/OSD +++ /dev/null @@ -1 +0,0 @@ -Versions/Current/OSD \ No newline at end of file diff --git a/OSD.framework/Resources b/OSD.framework/Resources deleted file mode 120000 index 953ee36..0000000 --- a/OSD.framework/Resources +++ /dev/null @@ -1 +0,0 @@ -Versions/Current/Resources \ No newline at end of file diff --git a/OSD.framework/Versions/A/.DS_Store b/OSD.framework/Versions/A/.DS_Store deleted file mode 100644 index 82fca9e..0000000 Binary files a/OSD.framework/Versions/A/.DS_Store and /dev/null differ diff --git a/OSD.framework/Versions/A/OSD b/OSD.framework/Versions/A/OSD deleted file mode 100755 index 58813ae..0000000 Binary files a/OSD.framework/Versions/A/OSD and /dev/null differ diff --git a/OSD.framework/Versions/A/Resources/Info.plist b/OSD.framework/Versions/A/Resources/Info.plist deleted file mode 100644 index 824b8c0..0000000 --- a/OSD.framework/Versions/A/Resources/Info.plist +++ /dev/null @@ -1,46 +0,0 @@ - - - - - BuildMachineOSBuild - 16B2657 - CFBundleDevelopmentRegion - en - CFBundleExecutable - OSD - CFBundleIdentifier - com.apple.OSD - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - OSD - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleSupportedPlatforms - - MacOSX - - CFBundleVersion - 1 - DTCompiler - com.apple.compilers.llvm.clang.1_0 - DTPlatformBuild - 9L173x - DTPlatformVersion - GM - DTSDKBuild - 17A317 - DTSDKName - macosx10.13internal - DTXcode - 0900 - DTXcodeBuild - 9L173x - NSHumanReadableCopyright - Copyright © 2015 Apple Inc. All rights reserved. - - diff --git a/OSD.framework/Versions/A/Resources/version.plist b/OSD.framework/Versions/A/Resources/version.plist deleted file mode 100644 index a387863..0000000 --- a/OSD.framework/Versions/A/Resources/version.plist +++ /dev/null @@ -1,18 +0,0 @@ - - - - - BuildAliasOf - OSDFramework - BuildVersion - 487 - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - ProjectName - OSDFramework - SourceVersion - 27000000000000 - - diff --git a/OSD.framework/Versions/A/_CodeSignature/CodeResources b/OSD.framework/Versions/A/_CodeSignature/CodeResources deleted file mode 100644 index cd4347d..0000000 --- a/OSD.framework/Versions/A/_CodeSignature/CodeResources +++ /dev/null @@ -1,139 +0,0 @@ - - - - - files - - Resources/Info.plist - - bTy7OXKIr2tY7ToPw28ekz1xUXU= - - Resources/version.plist - - d0I/dBV8v16urCBanZt9RaZvG1E= - - - files2 - - Resources/Info.plist - - hash2 - - uEmRq0D23jBsIWK+0+UH3bCcn16eQdAwKWglrQbTfQc= - - - Resources/version.plist - - hash2 - - f4xR2tymy1G7xEyxX1+yXJmSgOrrndsypu67avrQ8Ss= - - - - rules - - ^Resources/ - - ^Resources/.*\.lproj/ - - optional - - weight - 1000 - - ^Resources/.*\.lproj/locversion.plist$ - - omit - - weight - 1100 - - ^Resources/Base\.lproj/ - - weight - 1010 - - ^version.plist$ - - - rules2 - - .*\.dSYM($|/) - - weight - 11 - - ^(.*/)?\.DS_Store$ - - omit - - weight - 2000 - - ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/ - - nested - - weight - 10 - - ^.* - - ^Info\.plist$ - - omit - - weight - 20 - - ^PkgInfo$ - - omit - - weight - 20 - - ^Resources/ - - weight - 20 - - ^Resources/.*\.lproj/ - - optional - - weight - 1000 - - ^Resources/.*\.lproj/locversion.plist$ - - omit - - weight - 1100 - - ^Resources/Base\.lproj/ - - weight - 1010 - - ^[^/]+$ - - nested - - weight - 10 - - ^embedded\.provisionprofile$ - - weight - 20 - - ^version\.plist$ - - weight - 20 - - - - diff --git a/OSD.framework/Versions/Current b/OSD.framework/Versions/Current deleted file mode 120000 index 8c7e5a6..0000000 --- a/OSD.framework/Versions/Current +++ /dev/null @@ -1 +0,0 @@ -A \ No newline at end of file diff --git a/OSD.framework/XPCServices b/OSD.framework/XPCServices deleted file mode 120000 index 99c46ea..0000000 --- a/OSD.framework/XPCServices +++ /dev/null @@ -1 +0,0 @@ -Versions/Current/XPCServices \ No newline at end of file diff --git a/Podfile b/Podfile index 001b53d..01fe831 100644 --- a/Podfile +++ b/Podfile @@ -1,7 +1,47 @@ +platform :osx, '11.0' + target 'MultiSoundChanger' do # Comment the next line if you don't want to use dynamic frameworks use_frameworks! - pod 'SwiftLint' - pod 'MediaKeyTap', :git => 'https://github.com/the0neyouseek/MediaKeyTap.git', :branch => 'master' + pod 'SwiftLint', '~> 0.51', :inhibit_warnings => true + # MediaKeyTap's fork still uses the deprecated `class` keyword for class-constrained + # protocols and a few CFRelease-era patterns; inhibit the noise since it's third-party. + # Pin to a specific commit rather than :branch => 'master' so a compromise of the + # the0neyouseek fork can't silently land new code in our build on the next `pod install`. + pod 'MediaKeyTap', + :git => 'https://github.com/the0neyouseek/MediaKeyTap.git', + :commit => '22293b608bb9e7072960a2002d77ebbbdb3ba859', + :inhibit_warnings => true +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '11.0' + # Apply the same Xcode-recommended build hygiene to every Pods target that we apply to + # the main MultiSoundChanger target. Clicking Xcode's "Perform Changes" would edit + # Pods.xcodeproj, which CocoaPods regenerates on every `pod install` — so the only way + # for these to stick across regenerations is right here. + # + # Drop the legacy Swift-runtime embed (Swift runtime ships with macOS 10.14.4+; we target + # 11.0): + config.build_settings.delete('ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES') + config.build_settings.delete('EMBEDDED_CONTENT_CONTAINS_SWIFT') + # Let Xcode auto-pick ARCHS based on the active platform (Xcode's "Automatically Select + # Architectures" recommendation) — our universal binary still compiles for both arm64 + # and x86_64 via ARCHS_STANDARD: + config.build_settings.delete('ARCHS') + # Strip unreferenced code out of the release binary: + config.build_settings['DEAD_CODE_STRIPPING'] = 'YES' + # Xcode now complains about any explicit symbol-stripping overrides; reset to defaults: + config.build_settings.delete('STRIP_INSTALLED_PRODUCT') + config.build_settings.delete('STRIP_STYLE') + config.build_settings.delete('STRIP_SWIFT_SYMBOLS') + end + end + installer.pods_project.build_configurations.each do |config| + # Project-level: allow `xcodebuild -target …` to build independent targets in parallel. + config.build_settings['ENABLE_PARALLELIZATION_IN_CLI_BUILDS'] = 'YES' + end end diff --git a/README.md b/README.md index d71f097..b04b3d9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,16 @@ Latest release https://github.com/rlxone/MultiSoundChanger/releases A small tool for changing sound volume **even for aggregate devices** cause native sound volume controller can't change volume of aggregate devices (it was always pain in the ass with my laptop). - + +### ARM64 (Apple Silicon) Support + +This version has been rebuilt to support both Intel (x86_64) and Apple Silicon (ARM64) Macs natively. See [ARM64_MIGRATION.md](ARM64_MIGRATION.md) for details about the changes. + +**Architecture Support**: +- Intel Macs (x86_64) +- Apple Silicon Macs (ARM64: M1, M2, M3, M4) + +**Minimum macOS**: 11.0 (Big Sur). Earlier macOS versions are no longer supported in this fork; see [ARM64_MIGRATION.md](ARM64_MIGRATION.md) for details. Features: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..0d4c3f9 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,271 @@ +# Architecture deep-dive + +Companion to `CLAUDE.md`. Focus on subsystems that have nuance — the load-bearing, audit-scrutinized parts of the codebase. + +## Audio subsystem + +Two files. `Audio.swift` is the CoreAudio HAL wrapper; `AudioManager.swift` is the app-facing façade that adds user-intent semantics (mute state, aggregate-device awareness, debouncing). + +### `AudioImpl` (in `MultiSoundChanger/Sources/Frameworks/Audio.swift`) + +Every call into CoreAudio goes through the file-private `check(_ status:_ op:)` helper which: + +1. Returns `true` on `noErr`, `false` otherwise. +2. Logs non-success via `Logger.warning(...)` with the op label + numeric status code. +3. Rate-limits duplicates using a 2-second cooldown keyed by `(op, status)`. A `lastLoggedTimes` dictionary is capped at 64 entries so a flapping device can't grow it unbounded. The cap clears-then-inserts on overflow — losing cooldown memory briefly is acceptable; unbounded growth is not. +4. Serializes the cooldown dictionary via a dedicated `Self.logQueue = DispatchQueue(label: "…")` (default serial). + +`kAudioPropertyElement` is a file-scope `AudioObjectPropertyElement = 0` constant. This is both `kAudioObjectPropertyElementMain` (macOS 12+) and `kAudioObjectPropertyElementMaster` (deprecated since 12). The literal dodges the deprecation warning while preserving the runtime value. Don't try to use `#available` here — the fallback branch would still reference the deprecated symbol and re-produce the warning. + +HAL property writes (`setDeviceVolume`, `getDeviceVolume`) use `MemoryLayout.size` directly instead of a preceding `AudioObjectGetPropertyDataSize` probe — halved the IPC round-trips per volume event. The scalar is always a 32-bit float per Apple's HAL contract; don't reintroduce the probe. + +### Listener lifecycle (in an `extension AudioImpl` in the same file) + +`AudioListenerToken` is a `final class` with three `let` fields: `objectID`, `address`, `block`. All three must be immutable — the HAL matches the exact `(address, block)` pair that was registered when `removeListener` is called. A `var address` would let a future caller accidentally mutate it and silently orphan the HAL registration. + +`removeListener` copies the immutable `address` into a local `var` for the `inout` call to `AudioObjectRemovePropertyListenerBlock`. CoreAudio reads the struct fields; it doesn't need write access. + +`addHardwareListener` returns `AudioListenerToken?`. On HAL failure it returns `nil` — callers must `if let token = …` and skip appending. Returning a sentinel token that was never registered would cause `deinit` to call `removeListener` on a block the HAL doesn't know about. + +Blocks run on a dedicated serial `listenerQueue`. Inside the block we immediately `DispatchQueue.main.async { onChange() }` — every subsequent handler touch (menu mutation, delegate calls) must be main-thread. + +### `AudioManagerImpl` (in `MultiSoundChanger/Sources/Classes/AudioManager.swift`) + +Holds per-selected-device state: `selectedDevice: AudioDeviceID?`, `volumeBeforeMute: Float?`, `listenerTokens: [AudioListenerToken]`, and the debounce pair `pendingTargetVolume: Float?` + `pendingApplyItem: DispatchWorkItem?`. + +`init()`: + +- Enumerates `devices` from the HAL. +- Seeds `selectedDevice` from `audio.getDefaultOutputDevice()` **only if** the returned ID isn't `kAudioDeviceUnknown`. On HAL failure `selectedDevice` stays `nil`; hotkeys early-return cleanly instead of trying to write to device 0. +- Calls `registerListeners` to attach `kAudioHardwarePropertyDevices` and `kAudioHardwarePropertyDefaultOutputDevice` listeners. Each returns an optional token; only non-nil ones are appended. + +`deinit`: + +- Cancels `pendingApplyItem` first (no late HAL write trying to fire against a half-torn-down manager). +- Removes all listener tokens. + +`selectDevice(deviceID:)` vs `adoptSelectedDevice(deviceID:)`: + +- **`selectDevice`** is user-initiated. Updates `selectedDevice`, calls `audio.setOutputDevice(...)` to propagate to the system default. Only path that mutates the system's selected output. +- **`adoptSelectedDevice`** is system-initiated (the app *following* an external default-output change, or startup-matching the current default). Updates `selectedDevice` only — does NOT call `setOutputDevice`. + +This split exists because `syncDefaultOutputDevice` (the delegate callback when the system default changes) would otherwise write the new default back to the system, which might refire the default-output listener and loop. `populateDeviceList` also uses `adoptDevice` for the same reason at startup. + +Both paths invalidate any pending volume apply via `cancelPendingVolumeApply` — a queued write against the previous device must not land on the new one. + +### Volume debounce + +`setSelectedDeviceVolume(volume:)` doesn't write to the HAL synchronously. It: + +1. Stores `volume` in `pendingTargetVolume`. +2. Cancels any previously-scheduled `pendingApplyItem`. +3. Schedules a fresh `DispatchWorkItem` via `DispatchQueue.main.asyncAfter(deadline: .now() + 1.0/30.0)`. + +Rapid callers (hotkey repeat, slider drag) each overwrite `pendingTargetVolume` and cancel-and-reschedule. Only the final value actually round-trips to CoreAudio, 33 ms after the burst settles. `onMediaKeyTap` and the slider's `volumeSliderAction` both benefit. + +**`getSelectedDeviceVolume()` returns `pendingTargetVolume` first, falls back to a live HAL read.** This is critical for correctness: without it, the quantize-against-current step at the top of `onMediaKeyTap` would read the stale HAL value three times during rapid up-up-up and compute the same next step each time, collapsing three keypresses into one visible step. + +`readDeviceVolumeFromHAL()` is the uncached fallback — used by `toggleMute`'s "did the driver zero the scalar?" probe so it sees the actual device state, not user-intent. + +### `toggleMute` semantics + +`toggleMute` must: + +1. Cancel `pendingApplyItem` first, so a queued volume write can't fire after the mute flag is set and overwrite it via `setSelectedDeviceVolume`'s auto-mute branch. +2. Save the pre-mute volume (`intendedVolume = getSelectedDeviceVolume()`, which prefers pending) so on unmute we can restore if the driver zeroed the scalar during mute. +3. On unmute: if both `volumeBeforeMute >= lowerbound` AND post-unmute HAL reads `< lowerbound`, restore via `applyVolumeToHAL(pre)`. The `pre >= lowerbound` guard prevents an auto-mute loop when the user deliberately muted silence (volume was 0 before muting). + +### Aggregate devices — asymmetric read/write + +`audio.isAggregateDevice(deviceID:)` → `audio.getAggregateDeviceSubDeviceList(deviceID:)` returns all sub-devices. + +- **Writes** (`setSelectedDeviceVolume`, `setSelectedDeviceMute`): iterate every sub-device. Fan-out. +- **Reads** (`getSelectedDeviceVolume`, `isSelectedDeviceMuted`): return the first output sub-device's value. Read-one-sub-device. + +This mirrors how the app's physical model sees aggregates: "all sub-devices go up together, representative state from the first". Don't change this without understanding why — it's been raised multiple times in audits as asymmetric and each time confirmed as intentional. + +### Device-list refresh on hot-plug + +`handleDevicesChanged` is the delegate callback when the HAL's device list changes (USB DAC plugged/unplugged, aggregate created, etc.). It: + +1. Refreshes `devices` from the HAL. +2. If `selectedDevice` was removed from the new list, cancels any pending volume apply then reassigns to `audio.getDefaultOutputDevice()` (still guarded against `kAudioDeviceUnknown`). +3. Calls `delegate?.audioManagerDidChangeDevices(self)` so `ApplicationControllerImp` triggers `StatusBarController.refreshDeviceList()`. + +## OSD subsystem + +`MultiSoundChanger/Sources/Frameworks/NativeOSDManager.swift`. + +### Why it exists + +The original app linked Apple's private `OSD.framework` — x86_64-only, which blocked ARM64. We replaced it with a pure-Swift reimplementation that exposes an `@objc class OSDManager` with `sharedManager()` / `showImage(...)` matching the original framework's API. `MediaManager.showOSD` calls `OSDManager.sharedManager()` as if the framework still exists — **do not rename `OSDManager` or change `showImage`'s signature without updating `MediaManager.showOSD` in lockstep**. + +The on-disk `OSD.framework/` directory was a leftover, removed in an earlier commit. Don't re-add it; `project.pbxproj` and the bridging header no longer reference it. + +### Window reuse + fade + +`OSDWindow` is instantiated once per app lifetime. `showImage` either reuses the existing window or creates a fresh one if `osdWindow` is nil. The window's content is updated in place (`update(graphic:filledChiclets:totalChiclets:screen:)`) — don't recreate per event, that was the source of several crash-fix commits before the current design. + +Fade-out uses `NSAnimationContext.runAnimationGroup` with an explicit `completionHandler` that calls `orderOut`. Don't revert to the old raw `animator().alphaValue = 0` + `DispatchQueue.main.asyncAfter(0.3)` pattern — the two timelines could race and `orderOut` could land before the fade completed. + +`isReleasedWhenClosed = false` is deliberate — the window is reused across events, not closed. We never call `close()`, only `orderOut`. + +### Threading + +`sharedManager()` returns a `static let instance` — thread-safe initialization via Swift's static-let dispatch_once semantics, no manual locking. + +`showImage` branches on `Thread.isMainThread`. If already on main (the volume-hotkey path always is), it calls `displayOSD` synchronously — skipping the runloop hop saves a visible frame between keypress and OSD appearance. If called from a non-main context, it falls back to `DispatchQueue.main.async`. All access to `osdWindow` happens on main after this branch, so no explicit lock is needed. + +### Positioning + +`repositionOn(screen:)` uses `screen.visibleFrame` (not `screen.frame`) so the OSD respects the menu bar and dock on the primary display. Placement is `midY + height/4 - windowHeight/2` — intentionally above the screen's vertical center. Audit agents have flagged this as a sign error; it's not. + +### Enum + +`OSDGraphic` only has `.speaker` and `.speakerMuted`. The original framework's `.backlight`, `.eject`, `.noWiFi`, `.keyboardBacklightMeter`, `.macProOpen`, `.hotspot`, `.sleep` cases were dead code in our app and removed. + +## Media-key pipeline + +`MultiSoundChanger/Sources/Classes/MediaManager.swift`. + +### Registration + +`listenMediaKeyTaps()` is called once from `ApplicationControllerImp.start()`. It does three things: + +1. `observeMediaKeyOnAccessibilityApiChange()` — subscribes to `DistributedNotificationCenter`'s `com.apple.accessibility.api` so permission toggles re-register the tap without relaunch. +2. `acquirePrivileges()` — `AXIsProcessTrustedWithOptions(kAXTrustedCheckOptionPrompt: true)`. This prompts the user to grant Accessibility access. Called exactly **once** per process; don't move it back into `startMediaKeyTap` where it would re-prompt every time the accessibility notification fires. +3. `startMediaKeyTap()` — creates a `MediaKeyTap` instance for `[.volumeUp, .volumeDown, .mute]` with `observeBuiltIn: true` and starts it. Safe to call multiple times (stops the previous tap first). + +### Accessibility notification debounce + +`DistributedNotificationCenter` is a shared OS bus — **any local process can post** `com.apple.accessibility.api`. Without throttling, a hostile process could flood us and force CGEventTap to recreate itself in a loop (DoS — no code-exec, but CPU burn and event-tap exhaustion). + +`onAccessibilityNotification` uses a 500 ms trailing-edge debounce via `accessibilityNotificationWork: DispatchWorkItem?` — cancels the previous work item, schedules fresh. Legitimate single toggles still fire one restart; a flood collapses to one restart 500 ms after the last spoofed post. + +`deinit` cancels `accessibilityNotificationWork` before removing the observer, symmetric with `AudioManagerImpl.deinit` cancelling `pendingApplyItem`. + +### Event routing + +``` +MediaKeyTap (CGEventTap, DispatchQueue.main.sync delivery from fork) + → MediaManagerImpl.handle(mediaKey:event:modifiers:) [main] + → delegate?.onMediaKeyTap(mediaKey:) [main] + → ApplicationControllerImp.onMediaKeyTap(mediaKey:) [main] + → paint UI (OSD + slider + icon) via paintVolumeFeedback(_:) + → audioManager.setSelectedDeviceVolume(volume:) [volumeUp/Down] + OR audioManager.toggleMute() [.mute] +``` + +**UI paint precedes the HAL write for volumeUp/volumeDown.** This is deliberate. The HAL write is debounced + asynchronously applied; painting first makes the OSD and slider appear on the user's frame of the keypress, not after the CoreAudio round-trip. For `.mute`, the HAL write stays first because the OSD glyph depends on the post-toggle mute state. + +## Status bar / menu UI + +`MultiSoundChanger/Sources/Classes/StatusBarController.swift`. + +### `NSMenuDelegate` and deferred refresh + +`StatusBarControllerImpl` inherits from `NSObject` (required for `NSMenuDelegate` conformance) and implements: + +- `menuWillOpen(_:)` → sets `isMenuOpen = true`. +- `menuDidClose(_:)` → sets `isMenuOpen = false` and runs any `pendingRefresh` work. + +`refreshDeviceList()` is called from the `AudioManagerDelegate` path when the HAL reports a device topology change. If the menu is currently open, mutating NSMenu's items can crash AppKit's tracking machinery — so the method sets `pendingRefresh = true` and returns. `menuDidClose` drains the pending flag. This pattern exists because the alternative (always mutate, hope) used to crash on fast USB device hot-plug during an open menu. + +### Device-list reconstruction + +Device items are tracked in `deviceMenuItems: [NSMenuItem]` and anchored at `outputSectionAnchor: NSMenuItem?` (the disabled "Output Device:" label). `populateDeviceList(in:)`: + +- Sorts the devices dictionary by name (case-insensitive localized compare) — without this, dictionary iteration order is unspecified and the menu would reshuffle per launch. +- Inserts each device item at `anchorIndex + 1`. If the anchor is missing (shouldn't be, but defensive), logs a warning and returns — better to have a stale menu than items appended after the Quit item. + +### `selectDevice(device:)` vs `adoptDevice(_:)` (mirrors AudioManager) + +- `selectDevice` is used by `menuItemAction` (user clicked a row in the device menu). Propagates to the system default. +- `adoptDevice` is used by `populateDeviceList` (startup match to system default) and `syncDefaultOutputDevice` (external default-change notification). Does NOT propagate — would cause a listener loop. + +Both share `refreshUIForSelectedDevice()` which pulls the current volume, applies mute correction, and updates the slider + status-bar icon. + +### Status-bar icon bucketing + +`changeStatusItemImage(value:)` maps 0–100 → one of four icons at thresholds `<=1`, `<=33.33`, `<=66.66`, `else`. The `<=1` edge case is deliberate — `value == 1` (exactly 1%) used to fall into no bucket in the old `< 1 / > 1` form, leaving the icon stale. `100 / 3 * 2` evaluates to `(100/3)*2 ≈ 66.66`. Do not "correct" to `100/(3*2) = 16.67` — that would be wrong. + +## Logger + +`MultiSoundChanger/Sources/Utils/Logger.swift`. + +### File write path + +`Logger.debug/info/warning/error` → `outAndFilePrint(symbol:string:)`: + +1. **Synchronously** `print()` to stdout on the caller's thread (cheap). +2. **Async** on `fileWriteQueue` (serial background) → `filePrint` → `appendToFile`. + +Main thread is never blocked on FileManager or FileHandle syscalls. Before this refactor, every volume-hotkey press synchronously wrote to disk from `ApplicationControllerImp.onMediaKeyTap`, stuttering rapid key-repeat. + +### POSIX write with `O_NOFOLLOW` + +`appendToFile(url:content:)` opens via raw `Darwin.open(path, O_WRONLY|O_APPEND|O_CREAT|O_NOFOLLOW, 0o600)` — not `FileHandle(forWritingTo:)`. The reason is symlink defense: a local attacker planting a symlink at `~/Library/Caches//app.log` pointing to e.g. `~/.ssh/id_rsa` would otherwise cause our append to flow through the symlink and corrupt the target. `O_NOFOLLOW` makes `open()` return `ELOOP` if the final path component is a symlink. + +Creation mode `0o600` keeps the log file user-only regardless of the process's umask. + +`defer { Darwin.close(fd) }` on every success path — `write` errors don't leak the descriptor. + +### Write loop + +`Darwin.write` can return fewer bytes than requested on `EINTR`, signal interruption, or disk pressure. The loop: + +- Advances `offset` by the returned count on success. +- `continue`s on `errno == EINTR`. +- Breaks on `written == 0` (unusual but not strictly an error; abort). +- Escalates other errno values to a thrown `LoggerError.fileError(...)`. + +### `LoggerError` conforms to `LocalizedError` + +`filePrint` catches errors and surfaces them via `error.localizedDescription`. Without `LocalizedError` conformance, `localizedDescription` returns Cocoa's generic `"The operation couldn't be completed."` wrapper — and our carefully-constructed `"open(/path) failed: (errno=N)"` telemetry is replaced with a useless string. `errorDescription` on the enum returns the actual message. + +### `isLogFileRemoved` serialization + +Declared `private static var`. Access is NOT lock-guarded, **and that's fine** — every read/write happens inside the `fileWriteQueue.async` block, and that queue is serial (DispatchQueue with no attributes). This has been flagged as a race in multiple audits; each time the answer is "trace the call sites, they're all on the serial queue". The comment above the declaration spells this out. + +### `DateFormatter` cached + +`logDateFormatter: DateFormatter` is a `static let`, constructed once and reused. DateFormatter construction is orders of magnitude more expensive than `.string(from:)`; per-log-call construction was measurable. Apple documents DateFormatter as thread-safe for read use post-init, and `fileWriteQueue` serializes our access anyway. + +## Application lifecycle + +`AppDelegate.applicationDidFinishLaunching(_:)` calls `applicationController.start()`, which: + +1. `audioManager.delegate = self` — wired FIRST so any HAL listener callback queued during `AudioManagerImpl`'s construction finds a non-nil delegate when its main-queue continuation runs. +2. `statusBarController.createMenu()` — constructs the status item + menu, including the initial device list. +3. `mediaManager.listenMediaKeyTaps()` — registers the DistributedNotificationCenter observer, prompts Accessibility, starts the CGEventTap. + +Order matters. Don't rearrange. + +`ApplicationControllerImp` conforms to both `MediaManagerDelegate` (for `onMediaKeyTap`) and `AudioManagerDelegate` (for `audioManagerDidChangeDevices` / `audioManagerDidChangeDefaultOutputDevice`). Both delegates are declared `weak` on their managers — no retain cycles. + +## `AudioManager` + `StatusBarController` + debouncer interaction summary + +``` + Volume hotkey or slider drag + │ + ▼ + paintVolumeFeedback(_:) ← slider + icon + OSD appear INSTANTLY + │ + ▼ + audioManager.setSelectedDeviceVolume(volume:) + │ (stores pendingTargetVolume, schedules DispatchWorkItem +33ms) + ▼ + [33 ms trailing-edge fire on main] + │ + ▼ + applyVolumeToHAL(_:) ← actual CoreAudio IPC (fan-out on aggregate) + + Subsequent getSelectedDeviceVolume() calls return pendingTargetVolume + until the work item fires, then fall back to a live HAL read. + + toggleMute / selectDevice / adoptSelectedDevice / handleDevicesChanged + ALL call cancelPendingVolumeApply() first to prevent a queued write + from landing on wrong state. +``` + +This architecture is what makes rapid volume keys feel instant while also keeping the HAL in sync with user intent eventually. Preserve it. diff --git a/docs/AUDIT_NOTES.md b/docs/AUDIT_NOTES.md new file mode 100644 index 0000000..64751ce --- /dev/null +++ b/docs/AUDIT_NOTES.md @@ -0,0 +1,156 @@ +# Audit notes: things that LOOK like bugs but aren't + +This file exists because the codebase has been through ~15 audit passes (12 agents each) across security and general-bug sweeps. The same handful of patterns got flagged over and over — each was triaged, confirmed intentional, and the rationale captured here so future audits (and future Claude sessions) don't re-raise them. + +If a static analyzer, LSP, or audit agent flags something listed here, **it's not a bug**. The fix is already in place or the code is intentionally shaped this way. + +## Code patterns + +### 1. `AudioManagerImpl.readDeviceVolumeFromHAL` returns `nil` on an aggregate with no output sub-device + +**Flagged as**: "silent failure", "nil return breaks volume display". + +**Reality**: Documented design. See `docs/ARCHITECTURE.md` § aggregate devices. An aggregate legitimately has no volume to read if none of its sub-devices are outputs — returning `nil` is honest. Callers (`getSelectedDeviceVolume`, `toggleMute`'s unmute-restore check) handle nil correctly. + +### 2. `StatusBarController.changeStatusItemImage` thresholds `100 / 3 * 2` + +**Flagged as**: "operator precedence bug; should be `100 / (3 * 2) = 16.67`." + +**Reality**: The code is intentional. `100 / 3 * 2 = (100/3) * 2 ≈ 66.66` is the upper-third threshold for mapping volume → icon (four icons, three boundaries at 0, 33.33, 66.66, 100). The other way around would be nonsensical. + +### 3. `[Float].max()` returning `Float?` + +**Flagged as**: "nil-safety violation", "force-unwrap risk". + +**Reality**: Correct by design. `Array.max()` returns `Element?` because an empty array has no max. `getDeviceVolume` returns a 3-element array `[master, left, right]`, so `.max()` never nil in practice, but the optional signature is Swift's stdlib behavior. + +### 4. `Constants.chicletsCount = 16` "division by zero risk" + +**Flagged as**: "if chicletsCount is 0, `1 / Float(Constants.chicletsCount)` crashes". + +**Reality**: It's a compile-time constant literal `16`. It can't be 0 unless someone edits `Constants.swift` to make it 0. Not a runtime risk. + +### 5. `CFStringCreateWithSubstring` in `Audio.getDeviceName` + +**Flagged as**: "leaks the retained CFString; needs `takeRetainedValue()` or `CFRelease`". + +**Reality**: `CFStringCreateWithSubstring` is annotated `CF_RETURNS_RETAINED` in CoreFoundation, and Swift's CF bridge imports it as a Swift-ARC-managed `CFString?`. It is NOT an `Unmanaged?`. The local `truncated` var goes through standard Swift retain/release at function exit. No leak. + +### 6. HAL listener `onChange` strong capture + +**Flagged as**: "retain cycle — block captures onChange strongly". + +**Reality**: The block DOES capture `onChange` strongly, on purpose. `onChange` is what the caller passed in, and at the call site it's `{ [weak self] in self?.handleDevicesChanged() }` — so `onChange` weakly references `self`. If `self` deallocates, the closure body becomes a no-op. No cycle. + +### 7. `DispatchQueue.main.async` in the HAL listener block + +**Flagged as**: "redundant main-hop; should call `onChange` directly." + +**Reality**: The HAL listener fires on a dedicated background serial queue (`listenerQueue`). Every subsequent handler touches NSMenu / NSStatusItem / UI state, which must be on main. The hop is mandatory. + +### 8. `OSDWindow` mutation / `osdWindow` thread safety + +**Flagged as**: "race condition on `osdWindow` / `fadeTimer`". + +**Reality**: `OSDManager.showImage` branches on `Thread.isMainThread`. If on main, calls `displayOSD` synchronously; if off main, hops to main via `async`. Either way, `displayOSD` and all mutations of `osdWindow` / `fadeTimer` run exclusively on main. No concurrent access. + +### 9. `Logger.isLogFileRemoved` "race condition" + +**Flagged as**: "static mutable flag without a lock". + +**Reality**: All reads/writes of `isLogFileRemoved` happen inside `fileWriteQueue.async { try filePrint(…) } → removeLogFileIfNeeded`. `fileWriteQueue` is a `DispatchQueue(label:)` with no attributes — serial by default. A serial queue serializes every block it runs, so access is trivially sequenced. The comment at the declaration spells this out. + +### 10. `IBOutlet weak var foo: NSSlider!` "force unwrap" + +**Flagged as**: "force_unwrapping SwiftLint violation". + +**Reality**: The `!` is an implicitly-unwrapped-optional declaration, standard Swift idiom for storyboard-wired outlets. SwiftLint's `force_unwrapping` rule targets uses like `someOptional!`, not IUO declarations. + +### 11. `case empty = ""` "empty_string violation" + +**Flagged as**: "`Constants.Keys.empty = ""` violates `empty_string` rule." + +**Reality**: SwiftLint's `empty_string` rule targets `String()` initializer calls and `== ""` comparisons, not enum raw-value literals. The enum case's raw value is a compile-time constant string; there's nothing to flag. + +### 12. OSD y-position `screen.visibleFrame.midY + height/4` + +**Flagged as**: "sign error; should be `- height/4` for lower-half placement". + +**Reality**: Intentionally upper-center. `midY + height/4` places the OSD ~25% above the vertical center, matching where the original `OSD.framework` drew. Lower-half placement would conflict with the dock and Command-Tab UI area. + +### 13. `printDevices()` / `sanitizedForLog(_:)` in AudioManager "dead code" + +**Flagged as**: "never called / serves no purpose". + +**Reality**: `printDevices()` is called from `AudioManagerImpl.init()` (startup device enumeration for log). `sanitizedForLog` is called from `printDevices`. Both are live. The "only logs debug output" dismissal is wrong — logging IS the purpose. + +### 14. Partial write in `Logger.appendToFile` + +**Flagged as**: "single `Darwin.write` without a loop drops tail bytes on EINTR". + +**Reality**: There IS a loop — lines inside `data.withUnsafeBytes { buffer -> String? in var offset = 0; while offset < buffer.count { … } }`. It retries `EINTR`, bails on `write == 0`, escalates other errno values. Don't re-flag based on a skim. + +### 15. `VolumeViewController` "slider unit mismatch" + +**Flagged as**: "passes 0…100 UI value to HAL's 0…1 expected range". + +**Reality**: The code does `audioManager?.setSelectedDeviceVolume(volume: sliderValue / 100)`. Divides by 100. Read it. + +### 16. Ad-hoc code signing "security issue" + +**Flagged as**: "CODE_SIGN_IDENTITY = '-' is insecure for distribution". + +**Reality**: True for distribution, false for local dev / source-built use. For shipping, switch to Developer ID — see `docs/BUILD_AND_SIGNING.md`. The app is open-source and the maintainer doesn't distribute binaries themselves; ad-hoc is the right posture here. Don't flag this as if it's a blocking issue. + +### 17. `com.apple.security.cs.disable-library-validation` in entitlements "weakens security" + +**Flagged as**: "Hardened Runtime exception — violates principle of least privilege". + +**Reality**: Required for ad-hoc-signed apps with embedded CocoaPods frameworks. Without it, `Product → Archive → Export → double-click` fails to launch because library validation rejects the embedded MediaKeyTap.framework (ad-hoc signatures have no team identity to match). Narrow scope — affects which dylibs can load into this process only. Real risk requires write access to the .app bundle, which strictly subsumes dylib injection. Remove only when shipping via Developer ID + notarization, which makes the library validation work without exception. + +### 18. "`xcuserdata/` has `DynamicsIllusion.xcscheme`" + +**Flagged as**: "stale schema from pre-fork project". + +**Reality**: `xcuserdata/` is user-specific Xcode state, not part of the project definition. Those schemes belong to whoever originally created the repo's Xcode state; they don't affect builds on other machines. The tracked `project.pbxproj` and `.xcscheme` files under `xcshareddata/` are the authoritative version. Don't delete `xcuserdata/` content as a "fix". + +### 19. `@NSApplicationMain` "deprecated in Swift 5.3+" + +**Flagged as**: "should be `@main`". + +**Reality**: We tried `@main` once and it broke the build. `@main` requires the type itself to provide a `static func main()`; `@NSApplicationMain` emits an auto-generated `main()` that calls `NSApplicationMain()` which loads the Main storyboard. Our app relies on the storyboard loader, so we need `@NSApplicationMain`. Apple still supports it on macOS; the deprecation is a style warning at most. + +## Tool caveats + +### SourceKit single-file mode lies + +The editor's LSP (SourceKit-LSP) runs in single-file parse mode without the full module graph. It will constantly report: + +- `Cannot find type 'AudioManager' in scope` +- `Cannot find 'Logger' in scope` +- `Cannot find 'Constants' in scope` +- `'clamped' is inaccessible due to 'package' protection level` +- `No such module 'MediaKeyTap'` + +None of these are real. Always verify with the full-project `xcrun swiftc -typecheck` command in `docs/BUILD_AND_SIGNING.md`. + +### `swiftlint` CLI isn't installed on this user's machine + +Don't shell out to `swiftlint`. Do a manual read of `.swiftlint.yml` to get thresholds, then grep the code. The thresholds actually in use: + +- `line_length: 150` +- `type_body_length: warning 300, error 400` +- `file_length: warning 500, error 1000` +- `function_parameter_count: warning 10, error 20` + +Don't invent values like "file_length warning 400" and flag Audio.swift (currently ~450 lines) as over limit — the real warning threshold is 500. + +### `xcodebuild` is broken on this machine + +Unrelated `IDESimulatorFoundation` plugin load failure from Xcode. Don't try to run it. Use `swiftc -typecheck` as documented. + +## When in doubt + +If you spot something that looks like a bug and it's listed above: it isn't. If it's NOT listed and looks real: cross-check against the relevant subsystem section of `docs/ARCHITECTURE.md` before raising it. The architecture doc explains the load-bearing patterns in detail. + +If your "finding" requires three exclusions to justify ("but only on drivers that do X, and ignoring the documented aggregate model, and assuming the debouncer is reentrant…"), it's almost certainly theoretical rather than actionable. diff --git a/docs/BUILD_AND_SIGNING.md b/docs/BUILD_AND_SIGNING.md new file mode 100644 index 0000000..69574f9 --- /dev/null +++ b/docs/BUILD_AND_SIGNING.md @@ -0,0 +1,133 @@ +# Build, signing, and distribution posture + +Companion to `CLAUDE.md`. + +## Workspace vs project + +**Always open `MultiSoundChanger.xcworkspace`.** Not `MultiSoundChanger.xcodeproj`. The workspace is what CocoaPods integrates into; opening the raw project drops the Pods integration and the build fails to link `MediaKeyTap` + the SwiftLint phase's `PODS_ROOT` resolution breaks. + +## Typecheck without Xcode + +`xcodebuild` on this user's machine is broken (`IDESimulatorFoundation` plugin load failure — unrelated to this repo). For automated verification in a non-interactive shell, use `swiftc -typecheck` directly: + +```bash +# Emit the MediaKeyTap swiftmodule once (or whenever the pod is regenerated): +xcrun swiftc -emit-module \ + -target arm64-apple-macos11.0 \ + -module-name MediaKeyTap \ + -o /tmp/MediaKeyTap.swiftmodule \ + Pods/MediaKeyTap/MediaKeyTap/*.swift + +# Then typecheck the app: +xcrun swiftc -typecheck \ + -target arm64-apple-macos11.0 \ + -module-name MultiSoundChanger \ + -I /tmp \ + -import-objc-header MultiSoundChanger/Other/MultiSoundChanger-Bridging-Header.h \ + MultiSoundChanger/Sources/AppDelegate/AppDelegate.swift \ + MultiSoundChanger/Sources/Classes/*.swift \ + MultiSoundChanger/Sources/Extensions/*.swift \ + MultiSoundChanger/Sources/Frameworks/*.swift \ + MultiSoundChanger/Sources/Stories/Stories.swift \ + MultiSoundChanger/Sources/Stories/Volume/VolumeViewController.swift \ + MultiSoundChanger/Sources/Utils/*.swift \ + MultiSoundChanger/Other/Constants.swift \ + MultiSoundChanger/Other/Images.swift \ + MultiSoundChanger/Other/Localization/Strings.swift +``` + +Expected output: empty. The previous two `NSWorkspace.launchApplication` deprecation warnings have been resolved. Anything else means you've introduced a regression. + +**Do NOT trust SourceKit single-file diagnostics** emitted by the editor while you're editing a file. In isolation (no `-I` module path, no cross-file context) SourceKit will report "Cannot find type X" / "Cannot find 'Logger' in scope" for every cross-file reference. Those are almost always false — always cross-check with the full `swiftc -typecheck` command above before chasing them. + +## CocoaPods + +Two pods: `SwiftLint` (build-phase linter) and `MediaKeyTap` (CGEventTap wrapper). Both are dynamic (`use_frameworks!`). + +- **`MediaKeyTap` is pinned by commit hash**, NOT by branch. The Podfile uses `:commit => '22293b608bb9e7072960a2002d77ebbbdb3ba859'` against `the0neyouseek/MediaKeyTap`. Do not switch back to `:branch => 'master'` — a floating branch is a supply-chain attack vector. +- **`SwiftLint` is pinned to `'~> 0.51'`** — tilde-constraint semver. A breaking 1.x release won't walk in silently. + +`Podfile`'s `post_install` hook applies Xcode-recommended build hygiene to every pod target on each `pod install`, because `Pods.xcodeproj` is regenerated every install and any manual edits would be wiped. The hook currently sets: + +- `MACOSX_DEPLOYMENT_TARGET = '11.0'` +- Drops `ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES` + `EMBEDDED_CONTENT_CONTAINS_SWIFT` (Swift runtime ships with macOS 10.14.4+) +- Drops any explicit `ARCHS` override (Xcode auto-picks) +- `DEAD_CODE_STRIPPING = YES` +- Clears `STRIP_INSTALLED_PRODUCT` / `STRIP_STYLE` / `STRIP_SWIFT_SYMBOLS` (reset to defaults) +- `ENABLE_PARALLELIZATION_IN_CLI_BUILDS = YES` + +Module Verifier is deliberately NOT enabled for pods — it only matters for targets that define clang modules (our Swift-only pods don't), and turning it on blindly can trip on third-party header conformance we can't fix. + +**If `pod install` ever surfaces new warnings about `inhibit_warnings` or a pod version change, commit both `Podfile` and `Podfile.lock`. `Pods/` itself is gitignored.** + +## Hardened Runtime + entitlements + +Both Debug and Release target configurations in `project.pbxproj` have: + +``` +ENABLE_HARDENED_RUNTIME = YES; +CODE_SIGN_ENTITLEMENTS = MultiSoundChanger/Other/MultiSoundChanger.entitlements; +``` + +`MultiSoundChanger.entitlements` declares exactly one Hardened Runtime exception: + +```xml +com.apple.security.cs.disable-library-validation + +``` + +**Why**: CocoaPods' `use_frameworks!` embeds MediaKeyTap into the .app bundle as a dynamic framework. Hardened Runtime's default library-validation check refuses to load embedded dylibs unless they share a Team ID with the main binary. We sign ad-hoc (`CODE_SIGN_IDENTITY = "-"`) for open-source source-built distribution, which has no team, so without this exception the exported .app fails to launch with a library-load error. + +**Symptom this was introduced to fix**: Xcode test-build runs work (Launch Services is permissive for direct-run-from-DerivedData), but `Product → Archive → Export → double-click the .app` fails silently or with a Console error like `code signature in not valid for use in process: library load disallowed by system policy`. + +**Narrow scope**: library validation only. This exception does NOT weaken sandbox (none in use), JIT, debugger attach, or Apple Events posture. A real attack requires write access to the .app bundle, which is a precondition strictly worse than dylib injection. + +**What's still NOT declared** (and should only be added if a new feature concretely demands it): + +- App Sandbox — CGEventTap (via MediaKeyTap) is incompatible with sandbox. +- `com.apple.security.cs.allow-jit` / `allow-unsigned-executable-memory` — no JIT. +- `com.apple.security.automation.apple-events` — `NSWorkspace.openApplication(at:configuration:)` and `NSWorkspace.open(URL)` use LaunchServices, NOT Apple Events. +- `com.apple.security.cs.allow-dyld-environment-variables` — we don't use DYLD vars. + +**If shipping via Developer ID + notarization later**: the library-validation exception can be removed. CocoaPods' standard xcconfig re-signs embedded frameworks with the same team as the main binary during a Developer ID build, so library validation passes naturally. + +## Code signing + +`CODE_SIGN_IDENTITY = "-"` is ad-hoc signing — appropriate for local development but not distributable. For distribution: + +1. Set `CODE_SIGN_IDENTITY` to `"Developer ID Application"` (for direct distribution outside the App Store) on the Release config. +2. Set `DEVELOPMENT_TEAM` to the team ID. +3. Build the Release scheme: `xcodebuild -workspace MultiSoundChanger.xcworkspace -scheme MultiSoundChanger -configuration Release archive -archivePath build/MultiSoundChanger.xcarchive`. +4. Export as a Developer ID app: `xcodebuild -exportArchive -archivePath build/MultiSoundChanger.xcarchive -exportPath build/MultiSoundChanger-release -exportOptionsPlist ExportOptions.plist`. +5. Notarize: `xcrun notarytool submit build/MultiSoundChanger.app.zip --apple-id <...> --team-id <...> --password --wait`. +6. Staple: `xcrun stapler staple build/MultiSoundChanger.app`. + +The app is ready for this pipeline — Hardened Runtime is on, entitlements are set, and no blocking issues. The user's side of the work: obtain a Developer ID certificate + app-specific password, and optionally wire this into CI. + +## Info.plist + +Keys that matter: + +- `LSUIElement = true` — no dock icon, menu-bar only. +- `LSMinimumSystemVersion = $(MACOSX_DEPLOYMENT_TARGET)` — resolves to 11.0 via the Xcode build setting. +- `NSMainStoryboardFile = Main` — entry point. `Main.storyboard` contains the AppDelegate customObject; `customModule = "MultiSoundChanger"` (fixed in earlier commit — used to be `"DynamicsIllusion"` from whatever project this forked from). +- `NSPrincipalClass = NSApplication`. +- `NSAccessibilityUsageDescription` — required for the Accessibility prompt. Explains to the user (and to reviewers looking at a notarized binary) why the app asks for this permission. + +## Xcode "Update to recommended settings" dialog + +Xcode will periodically pop this dialog with "Perform Changes" recommendations. Rules of thumb: + +- **For the main `MultiSoundChanger` project**: safe to click Perform Changes. Those edits land in the tracked `project.pbxproj` and can be reviewed as a git diff. Exception: `Enable User Script Sandboxing` may break the SwiftLint build phase — if SwiftLint stops running after applying, disable the phase's "Based on dependency analysis" toggle (which is also how we fixed the "run during every build" warning). +- **For the `Pods` project**: do NOT click Perform Changes. Those edits land in `Pods.xcodeproj` which CocoaPods regenerates on next `pod install`, wiping the changes. Add the equivalent build settings to `Podfile`'s `post_install` hook instead (see above). + +## SwiftLint phase + +In `project.pbxproj` the SwiftLint `PBXShellScriptBuildPhase` has `alwaysOutOfDate = 1;` set — equivalent to unticking "Based on dependency analysis" in the Build Phase inspector. SwiftLint produces diagnostics, not output files; Xcode's dependency analysis would otherwise warn "will be run during every build". The phase is deliberately unconditional on Debug and early-exits on Release. + +## Git workflow + +- Branch: `claude/rebuild-x86-app-011CV4gXVczxQsxNuHeA9X9o`. +- Upstream PR: #39 on `rlxone/MultiSoundChanger`. +- Push: `git push origin claude/rebuild-x86-app-011CV4gXVczxQsxNuHeA9X9o`. Credentials live in macOS Keychain via the `osxkeychain` git helper; no manual auth needed. +- `.gitignore` excludes `Pods/`, `*.xcworkspace`, `Podfile.lock`, `.DS_Store`. **`Podfile.lock` is in `.gitignore` — don't commit it.** (Some projects commit it; this one doesn't, which means every fresh clone may resolve to slightly different pod versions. The `:commit` pin on MediaKeyTap and `:version` constraint on SwiftLint keep this safe.)