From 69c696325e75207b290e2598875124225457955d Mon Sep 17 00:00:00 2001 From: isaaclins <104733575+isaaclins@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:31:58 +0200 Subject: [PATCH 1/4] fix(equalizer): install HAL driver through SMAppService helper (#357) --- Makefile | 26 +- Spotiglass.xcodeproj/project.pbxproj | 171 ++++++++++ Spotiglass/App/SparkleInfo.plist | 2 + Spotiglass/App/SpotiglassApp.swift | 5 +- Spotiglass/Localizable.xcstrings | 69 ---- .../Playback/AudioEqualizerEngine.swift | 46 ++- .../EqualizerDriverInstallPolicy.swift | 141 ++++++++ .../EqualizerHALPluginController.swift | 315 ++++++++++-------- .../EqualizerPrivilegedHelperClient.swift | 281 ++++++++++++++++ .../EqualizerPrivilegedHelperIdentity.swift | 67 ++++ SpotiglassEQDriver/README.md | 10 +- SpotiglassEQDriver/build-driver.sh | 33 +- SpotiglassEQPrivilegedHelper/Info.plist | 20 ++ ...aclins.spotiglass.eqprivilegedhelper.plist | 17 + SpotiglassEQPrivilegedHelper/main.swift | 238 +++++++++++++ .../EqualizerDriverInstallPolicyTests.swift | 132 ++++++++ SpotiglassTests/EqualizerHALPluginTests.swift | 62 +++- docs/building-and-testing.md | 4 +- docs/ci-and-releases.md | 12 +- docs/equalizer-proof.md | 96 +++--- docs/equalizer-qa.md | 12 +- docs/equalizer-xcode-target.md | 52 ++- docs/equalizer.md | 68 ++-- scripts/coverage-allowlist.json | 1 + scripts/eq-qa.sh | 41 +-- scripts/setup-eq-driver-signing.sh | 8 +- scripts/sparkle-release.sh | 12 +- 27 files changed, 1554 insertions(+), 387 deletions(-) create mode 100644 Spotiglass/Playback/EqualizerDriverInstallPolicy.swift create mode 100644 Spotiglass/Playback/EqualizerPrivilegedHelperClient.swift create mode 100644 Spotiglass/Playback/EqualizerPrivilegedHelperIdentity.swift create mode 100644 SpotiglassEQPrivilegedHelper/Info.plist create mode 100644 SpotiglassEQPrivilegedHelper/com.isaaclins.spotiglass.eqprivilegedhelper.plist create mode 100644 SpotiglassEQPrivilegedHelper/main.swift create mode 100644 SpotiglassTests/EqualizerDriverInstallPolicyTests.swift diff --git a/Makefile b/Makefile index 565458d..87d8e82 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,11 @@ DERIVED_DATA := build/DerivedData DEBUG_APP := $(DERIVED_DATA)/Build/Products/Debug/Spotiglass.app RELEASE_APP := $(DERIVED_DATA)/Build/Products/Release/Spotiglass.app +# Keep standalone driver bundles versioned with the project so install policy +# can tell a new payload from a driver left by an older app release. +DRIVER_MARKETING_VERSION ?= $(shell awk -F'= ' '/MARKETING_VERSION =/ { gsub(";", "", $$2); print $$2; exit }' $(PROJECT)/project.pbxproj 2>/dev/null || echo 0.1.0) +DRIVER_BUILD_VERSION ?= $(shell awk -F'= ' '/CURRENT_PROJECT_VERSION =/ { gsub(";", "", $$2); print $$2; exit }' $(PROJECT)/project.pbxproj 2>/dev/null || echo 1) + # Must match KeychainRefreshTokenStore.service in Spotiglass/Persistence/AuthPersistence.swift KEYCHAIN_SERVICE := com.isaaclins.spotiglass.spotify-auth @@ -94,11 +99,16 @@ audit-eq-permission: # Pass SPOTIGLASS_EQ_DEBUG=1 to compile in verbose driver diagnostic logging # (per-cycle DoIO / StartIO / OutputCallback events to /tmp). Off by default. build-driver: + DRIVER_MARKETING_VERSION="$(DRIVER_MARKETING_VERSION)" \ + DRIVER_BUILD_VERSION="$(DRIVER_BUILD_VERSION)" \ + DRIVER_CODESIGN_IDENTITY="$(LOCAL_SIGN_IDENTITY)" \ + DRIVER_CODESIGN_KEYCHAIN="$(HOME)/Library/Keychains/login.keychain-db" \ SPOTIGLASS_EQ_DEBUG=$(SPOTIGLASS_EQ_DEBUG) ./SpotiglassEQDriver/build-driver.sh # Builds the .driver and copies it into the Debug Spotiglass.app at # Contents/Library/Audio/Plug-Ins/HAL/. After this, the app's -# EqualizerHALPluginController can find and install the embedded driver. +# EqualizerHALPluginController can find the embedded driver and ask its +# registered privileged helper to install it into the system HAL directory. # # IMPORTANT: cp -pR preserves the source file's mtime so the kernel's # code-signing check (cs_mtime vs file mtime) keeps passing after the copy. @@ -109,15 +119,19 @@ embed-driver: build build-driver mkdir -p "$$dst"; \ rm -rf "$$dst/SpotiglassEQDriver.driver"; \ cp -pR build/SpotiglassEQDriver.driver "$$dst/"; \ + if [ -n "$(LOCAL_SIGN_IDENTITY)" ] && [ "$(UNSIGNED)" != "1" ]; then \ + codesign --force --sign "$(LOCAL_SIGN_IDENTITY)" --keychain "$(HOME)/Library/Keychains/login.keychain-db" \ + --entitlements Spotiglass/Spotiglass.entitlements --timestamp=none "$(DEBUG_APP)"; \ + fi; \ echo "embedded → $$dst/SpotiglassEQDriver.driver"; \ echo; \ - echo "To activate after first launch:"; \ - echo " sudo killall coreaudiod"; \ - echo "(or log out and back in)" + echo "After first launch, enable Equalizer in Settings:"; \ + echo " macOS will authorize the helper and restart coreaudiod" # Re-signs build/SpotiglassEQDriver.driver with the user's Apple Development -# identity. The build-driver step leaves the bundle ad-hoc signed (coreaudiod -# rejects ad-hoc on macOS 26). Override CODESIGN_IDENTITY to use a different +# identity when a different identity is needed. Standalone driver builds are +# ad-hoc unless DRIVER_CODESIGN_IDENTITY is supplied; coreaudiod rejects those +# on macOS 26. Override CODESIGN_IDENTITY to use a different # identity. If signing fails with errSecInternalComponent, run # `bash scripts/setup-eq-driver-signing.sh` first to trust Apple Root CA. CODESIGN_IDENTITY ?= $(shell security find-identity -v -p codesigning | awk '/Apple Development/ { print $$2; exit }') diff --git a/Spotiglass.xcodeproj/project.pbxproj b/Spotiglass.xcodeproj/project.pbxproj index b14c86d..59caf2f 100644 --- a/Spotiglass.xcodeproj/project.pbxproj +++ b/Spotiglass.xcodeproj/project.pbxproj @@ -223,6 +223,16 @@ SRC0000000000000000000013 /* EQCoefficients.swift in Sources */ = {isa = PBXBuildFile; fileRef = SRC0000000000000000080013 /* EQCoefficients.swift */; }; SRC0000000000000000000014 /* EQCoefficientPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = SRC0000000000000000080014 /* EQCoefficientPublisher.swift */; }; SRC0000000000000000000015 /* EqualizerHALPluginController.swift in Sources */ = {isa = PBXBuildFile; fileRef = SRC0000000000000000080015 /* EqualizerHALPluginController.swift */; }; + EQPOLICY000000000000001 /* EqualizerDriverInstallPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = EQPOLICY000000000000002 /* EqualizerDriverInstallPolicy.swift */; }; + EQCLIENT000000000000001 /* EqualizerPrivilegedHelperClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = EQCLIENT000000000000002 /* EqualizerPrivilegedHelperClient.swift */; }; + EQIDENTITY000000000001 /* EqualizerPrivilegedHelperIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = EQIDENTITY000000000002 /* EqualizerPrivilegedHelperIdentity.swift */; }; + EQHELPERIDENTITY000000001 /* EqualizerPrivilegedHelperIdentity.swift in Helper Sources */ = {isa = PBXBuildFile; fileRef = EQIDENTITY000000000002 /* EqualizerPrivilegedHelperIdentity.swift */; }; + EQHELPERMAIN0000000000001 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = EQHELPERMAIN0000000000002 /* main.swift */; }; + EQHELPERPLIST0000000000001 /* com.isaaclins.spotiglass.eqprivilegedhelper.plist in CopyFiles */ = {isa = PBXBuildFile; fileRef = EQHELPERPLIST0000000000002 /* com.isaaclins.spotiglass.eqprivilegedhelper.plist */; }; + EQHELPEREMBED000000000001 /* SpotiglassEQPrivilegedHelper in Embed Privileged Helper */ = {isa = PBXBuildFile; fileRef = EQHELPERPRODUCT000000000001 /* SpotiglassEQPrivilegedHelper */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + EQSMFRAMEWORK00000000000001 /* ServiceManagement.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EQSMFRAMEWORK00000000000002 /* ServiceManagement.framework */; }; + EQSECURITY000000000001 /* Security.framework in Helper Frameworks */ = {isa = PBXBuildFile; fileRef = EQSECURITY000000000002 /* Security.framework */; }; + EQPOLICYTEST0000000000001 /* EqualizerDriverInstallPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EQPOLICYTEST0000000000002 /* EqualizerDriverInstallPolicyTests.swift */; }; TST00000000000000000000D0 /* SpotifyPlaybackAPITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TST00000000000000000100D0 /* SpotifyPlaybackAPITests.swift */; }; TST00000000000000000000D1 /* LoopbackOAuthCallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TST00000000000000000100D1 /* LoopbackOAuthCallbackTests.swift */; }; TST00000000000000000000D2 /* SpotifyLocalCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TST00000000000000000100D2 /* SpotifyLocalCacheTests.swift */; }; @@ -366,8 +376,40 @@ remoteGlobalIDString = A10000000000000000000040; remoteInfo = Spotiglass; }; + EQHELPERPROXY000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A10000000000000000000030 /* Project object */; + proxyType = 1; + remoteGlobalIDString = EQHELPERTARGET000000001; + remoteInfo = SpotiglassEQPrivilegedHelper; + }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + EQHELPEREMBEDPHASE00001 /* Embed Privileged Helper */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = Contents/Library/PrivilegedHelperTools; + dstSubfolderSpec = 1; + files = ( + EQHELPEREMBED000000000001 /* SpotiglassEQPrivilegedHelper in Embed Privileged Helper */, + ); + name = "Embed Privileged Helper"; + runOnlyForDeploymentPostprocessing = 0; + }; + EQHELPERPLISTPHASE00001 /* Embed Privileged Helper Plist */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = Contents/Library/LaunchDaemons; + dstSubfolderSpec = 1; + files = ( + EQHELPERPLIST0000000000001 /* com.isaaclins.spotiglass.eqprivilegedhelper.plist in CopyFiles */, + ); + name = "Embed Privileged Helper Plist"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 728E7A032FEAA68100FB3C01 /* HomeFeedModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeFeedModels.swift; sourceTree = ""; }; 728E7A052FEAA68E00FB3C01 /* PlaylistBrowserViewModel+Home.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PlaylistBrowserViewModel+Home.swift"; sourceTree = ""; }; @@ -720,6 +762,16 @@ TST0000000000000000010120 /* SpotiglassLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotiglassLogTests.swift; sourceTree = ""; }; TST0000000000000000010121 /* EqualizerHALPluginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerHALPluginTests.swift; sourceTree = ""; }; TST0000000000000000010122 /* EqualizerABRMSTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerABRMSTests.swift; sourceTree = ""; }; + EQPOLICY000000000000002 /* EqualizerDriverInstallPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerDriverInstallPolicy.swift; sourceTree = ""; }; + EQCLIENT000000000000002 /* EqualizerPrivilegedHelperClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerPrivilegedHelperClient.swift; sourceTree = ""; }; + EQIDENTITY000000000002 /* EqualizerPrivilegedHelperIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerPrivilegedHelperIdentity.swift; sourceTree = ""; }; + EQHELPERMAIN0000000000002 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + EQHELPERPLIST0000000000002 /* com.isaaclins.spotiglass.eqprivilegedhelper.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = com.isaaclins.spotiglass.eqprivilegedhelper.plist; sourceTree = ""; }; + EQHELPERINFO0000000000001 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + EQHELPERPRODUCT000000000001 /* SpotiglassEQPrivilegedHelper */ = {isa = PBXFileReference; includeInIndex = 0; path = SpotiglassEQPrivilegedHelper; sourceTree = BUILT_PRODUCTS_DIR; }; + EQSMFRAMEWORK00000000000002 /* ServiceManagement.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ServiceManagement.framework; path = System/Library/Frameworks/ServiceManagement.framework; sourceTree = SDKROOT; }; + EQSECURITY000000000002 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; + EQPOLICYTEST0000000000002 /* EqualizerDriverInstallPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerDriverInstallPolicyTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -728,6 +780,7 @@ buildActionMask = 2147483647; files = ( PKG00000000000000000005 /* Sparkle in Frameworks */, + EQSMFRAMEWORK00000000000001 /* ServiceManagement.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -739,6 +792,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + EQHELPERFRAMEWORKSPHASE0001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + EQSECURITY000000000001 /* Security.framework in Helper Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -748,6 +809,7 @@ A10000000000000000000061 /* Spotiglass */, A10000000000000000000068 /* SpotiglassTests */, A10000000000000000000069 /* Products */, + EQHELPERGROUP000000000001 /* SpotiglassEQPrivilegedHelper */, ); sourceTree = ""; }; @@ -775,6 +837,16 @@ path = Spotiglass; sourceTree = ""; }; + EQHELPERGROUP000000000001 /* SpotiglassEQPrivilegedHelper */ = { + isa = PBXGroup; + children = ( + EQHELPERINFO0000000000001 /* Info.plist */, + EQHELPERPLIST0000000000002 /* com.isaaclins.spotiglass.eqprivilegedhelper.plist */, + EQHELPERMAIN0000000000002 /* main.swift */, + ); + path = SpotiglassEQPrivilegedHelper; + sourceTree = ""; + }; A10000000000000000000062 /* App */ = { isa = PBXGroup; children = ( @@ -854,6 +926,9 @@ E50000000000000000000015 /* HiddenPlaybackWebView.swift */, E50000000000000000000016 /* PlaybackControlsView.swift */, SRC0000000000000000080015 /* EqualizerHALPluginController.swift */, + EQCLIENT000000000000002 /* EqualizerPrivilegedHelperClient.swift */, + EQIDENTITY000000000002 /* EqualizerPrivilegedHelperIdentity.swift */, + EQPOLICY000000000000002 /* EqualizerDriverInstallPolicy.swift */, SRC0000000000000000080014 /* EQCoefficientPublisher.swift */, SRC0000000000000000080013 /* EQCoefficients.swift */, SRC0000000000000000080012 /* AudioEqualizerEngine.swift */, @@ -916,6 +991,7 @@ TST0000000000000000010123 /* RealUserDefaultsIsolationTests.swift */, TST000000000000000001010D /* SettingsWindowChromeTests.swift */, TST0000000000000000010122 /* EqualizerABRMSTests.swift */, + EQPOLICYTEST0000000000002 /* EqualizerDriverInstallPolicyTests.swift */, TST0000000000000000010121 /* EqualizerHALPluginTests.swift */, EFT000000000000000000002 /* EqualizerForwardingTargetTests.swift */, EQR000000000000000000002 /* EqualizerSettingsRowTests.swift */, @@ -1036,6 +1112,7 @@ children = ( A10000000000000000000010 /* Spotiglass.app */, A10000000000000000000017 /* SpotiglassTests.xctest */, + EQHELPERPRODUCT000000000001 /* SpotiglassEQPrivilegedHelper */, ); name = Products; sourceTree = ""; @@ -1293,10 +1370,13 @@ A10000000000000000000070 /* Sources */, A10000000000000000000050 /* Frameworks */, A10000000000000000000071 /* Resources */, + EQHELPERPLISTPHASE00001 /* Embed Privileged Helper Plist */, + EQHELPEREMBEDPHASE00001 /* Embed Privileged Helper */, ); buildRules = ( ); dependencies = ( + EQHELPERDEPENDENCY00001 /* PBXTargetDependency */, ); name = Spotiglass; packageProductDependencies = ( @@ -1306,6 +1386,24 @@ productReference = A10000000000000000000010 /* Spotiglass.app */; productType = "com.apple.product-type.application"; }; + EQHELPERTARGET000000001 /* SpotiglassEQPrivilegedHelper */ = { + isa = PBXNativeTarget; + buildConfigurationList = EQHELPERCONFIGLIST000001 /* Build configuration list for PBXNativeTarget "SpotiglassEQPrivilegedHelper" */; + buildPhases = ( + EQHELPERSOURCESPHASE00001 /* Sources */, + EQHELPERFRAMEWORKSPHASE0001 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SpotiglassEQPrivilegedHelper; + packageProductDependencies = ( + ); + productName = SpotiglassEQPrivilegedHelper; + productReference = EQHELPERPRODUCT000000000001 /* SpotiglassEQPrivilegedHelper */; + productType = "com.apple.product-type.tool"; + }; A10000000000000000000041 /* SpotiglassTests */ = { isa = PBXNativeTarget; buildConfigurationList = A10000000000000000000091 /* Build configuration list for PBXNativeTarget "SpotiglassTests" */; @@ -1340,6 +1438,9 @@ A10000000000000000000040 = { CreatedOnToolsVersion = 26.0; }; + EQHELPERTARGET000000001 = { + CreatedOnToolsVersion = 26.0; + }; A10000000000000000000041 = { CreatedOnToolsVersion = 26.0; TestTargetID = A10000000000000000000040; @@ -1364,6 +1465,7 @@ projectRoot = ""; targets = ( A10000000000000000000040 /* Spotiglass */, + EQHELPERTARGET000000001 /* SpotiglassEQPrivilegedHelper */, A10000000000000000000041 /* SpotiglassTests */, ); }; @@ -1391,6 +1493,15 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + EQHELPERSOURCESPHASE00001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + EQHELPERMAIN0000000000001 /* main.swift in Sources */, + EQHELPERIDENTITY000000001 /* EqualizerPrivilegedHelperIdentity.swift in Helper Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; A10000000000000000000070 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1548,6 +1659,9 @@ E50000000000000000000008 /* QueueViewModel.swift in Sources */, E50000000000000000000006 /* PlaybackControlsView.swift in Sources */, SRC0000000000000000000015 /* EqualizerHALPluginController.swift in Sources */, + EQCLIENT000000000000001 /* EqualizerPrivilegedHelperClient.swift in Sources */, + EQIDENTITY000000000001 /* EqualizerPrivilegedHelperIdentity.swift in Sources */, + EQPOLICY000000000000001 /* EqualizerDriverInstallPolicy.swift in Sources */, SRC0000000000000000000014 /* EQCoefficientPublisher.swift in Sources */, SRC0000000000000000000013 /* EQCoefficients.swift in Sources */, SRC0000000000000000000012 /* AudioEqualizerEngine.swift in Sources */, @@ -1634,6 +1748,7 @@ TST0000000000000000000123 /* RealUserDefaultsIsolationTests.swift in Sources */, TST000000000000000000010D /* SettingsWindowChromeTests.swift in Sources */, TST0000000000000000000122 /* EqualizerABRMSTests.swift in Sources */, + EQPOLICYTEST0000000000001 /* EqualizerDriverInstallPolicyTests.swift in Sources */, TST0000000000000000000121 /* EqualizerHALPluginTests.swift in Sources */, EFT000000000000000000001 /* EqualizerForwardingTargetTests.swift in Sources */, EQR000000000000000000001 /* EqualizerSettingsRowTests.swift in Sources */, @@ -1752,6 +1867,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + EQHELPERDEPENDENCY00001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EQHELPERTARGET000000001 /* SpotiglassEQPrivilegedHelper */; + targetProxy = EQHELPERPROXY000000000001 /* PBXContainerItemProxy */; + }; A10000000000000000000021 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = A10000000000000000000040 /* Spotiglass */; @@ -1760,6 +1880,48 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + EQHELPERDEBUGCONFIG0001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CREATE_INFOPLIST_SECTION_IN_BINARY = YES; + CURRENT_PROJECT_VERSION = 9; + DEVELOPMENT_TEAM = BHAF4L4726; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = SpotiglassEQPrivilegedHelper/Info.plist; + INFOPLIST_KEY_CFBundleIdentifier = com.isaaclins.spotiglass.eqprivilegedhelper; + MACOSX_DEPLOYMENT_TARGET = 26.0; + PRODUCT_BUNDLE_IDENTIFIER = com.isaaclins.spotiglass.eqprivilegedhelper; + PRODUCT_NAME = SpotiglassEQPrivilegedHelper; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + EQHELPERRELEASECONFIG001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CREATE_INFOPLIST_SECTION_IN_BINARY = YES; + CURRENT_PROJECT_VERSION = 9; + DEVELOPMENT_TEAM = BHAF4L4726; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = SpotiglassEQPrivilegedHelper/Info.plist; + INFOPLIST_KEY_CFBundleIdentifier = com.isaaclins.spotiglass.eqprivilegedhelper; + MACOSX_DEPLOYMENT_TARGET = 26.0; + PRODUCT_BUNDLE_IDENTIFIER = com.isaaclins.spotiglass.eqprivilegedhelper; + PRODUCT_NAME = SpotiglassEQPrivilegedHelper; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; A10000000000000000000080 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1996,6 +2158,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + EQHELPERCONFIGLIST000001 /* Build configuration list for PBXNativeTarget "SpotiglassEQPrivilegedHelper" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EQHELPERDEBUGCONFIG0001 /* Debug */, + EQHELPERRELEASECONFIG001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; A10000000000000000000090 /* Build configuration list for PBXNativeTarget "Spotiglass" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Spotiglass/App/SparkleInfo.plist b/Spotiglass/App/SparkleInfo.plist index 58e73ff..8f71f80 100644 --- a/Spotiglass/App/SparkleInfo.plist +++ b/Spotiglass/App/SparkleInfo.plist @@ -4,6 +4,8 @@ SUFeedURL https://isaaclins.com/spotiglass/appcast.xml + SpotiglassCodeSigningTeamIdentifier + $(DEVELOPMENT_TEAM) SUPublicEDKey HknEj0Snyq5WsrWwAxj89njv+qkdMASLlzKMFrlog8Y= SUEnableAutomaticChecks diff --git a/Spotiglass/App/SpotiglassApp.swift b/Spotiglass/App/SpotiglassApp.swift index 59cff31..85c0cb8 100644 --- a/Spotiglass/App/SpotiglassApp.swift +++ b/Spotiglass/App/SpotiglassApp.swift @@ -70,9 +70,8 @@ struct SpotiglassApp: App { /// Re-engage the EQ only when the persisted master switch was on. The /// settings toggle is the source of truth across launches: an off value - /// leaves the engine stopped, while an unavailable driver makes the failed - /// enable explicit and resets the persisted switch instead of claiming the - /// EQ is active. + /// leaves the engine stopped, while an unavailable driver is logged and the + /// persisted switch is reset instead of claiming the EQ is active. private static func restoreEqualizerIfEnabled( settingsStore: SpotiglassSettingsStore, engine: AudioEqualizerEngine diff --git a/Spotiglass/Localizable.xcstrings b/Spotiglass/Localizable.xcstrings index ba661ff..0c6d3bd 100644 --- a/Spotiglass/Localizable.xcstrings +++ b/Spotiglass/Localizable.xcstrings @@ -4000,75 +4000,6 @@ } } }, - "eq.error.driverNotLoadedYet": { - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "macOS hat den Spotiglass-Audiotreiber noch nicht aktiviert. Melden Sie sich ab und wieder an und schalten Sie den Equalizer erneut ein." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "macOS has not activated the Spotiglass audio driver yet. Log out and back in, then turn the equalizer on again." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "macOS todavía no ha activado el controlador de audio de Spotiglass. Cierra sesión y vuelve a entrar, y activa el ecualizador de nuevo." - } - } - } - }, - "eq.error.embeddedDriverMissing": { - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Spotiglass fehlt der Audiotreiber. Installieren Sie Spotiglass neu, um ihn wiederherzustellen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Spotiglass is missing its audio driver. Reinstall Spotiglass to restore it." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "A Spotiglass le falta su controlador de audio. Reinstala Spotiglass para restaurarlo." - } - } - } - }, - "eq.error.requiresSudoInstall": { - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Spotiglass kann den Audiotreiber nicht selbst installieren, da macOS Audio-Plug-ins nur aus einem Systemordner lädt. Führen Sie zum Abschluss diese beiden Befehle im Terminal aus und schalten Sie den Equalizer danach erneut ein:\n\n%@" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Spotiglass cannot install the audio driver itself, because macOS only loads audio plug-ins from a system folder. To finish, run these two commands in Terminal and then turn the equalizer on again:\n\n%@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Spotiglass no puede instalar el controlador de audio por sí mismo, porque macOS solo carga complementos de audio desde una carpeta del sistema. Para terminar, ejecuta estos dos comandos en Terminal y activa el ecualizador de nuevo:\n\n%@" - } - } - } - }, "error.browsing.accessDenied.message": { "extractionState": "stale", "localizations": { diff --git a/Spotiglass/Playback/AudioEqualizerEngine.swift b/Spotiglass/Playback/AudioEqualizerEngine.swift index f426ed1..379c0c5 100644 --- a/Spotiglass/Playback/AudioEqualizerEngine.swift +++ b/Spotiglass/Playback/AudioEqualizerEngine.swift @@ -29,7 +29,7 @@ enum EqualizerRouteState: Equatable { case disabled case starting(targetUID: String?) case live(targetUID: String, errorMessage: String?) - case failed(message: String, isEngaged: Bool) + case failed(message: String?, isEngaged: Bool) var isLive: Bool { if case .live = self { return true } @@ -209,7 +209,10 @@ final class AudioEqualizerEngine: ObservableObject { publishCoefficients() } catch { recordFailure(error) - routeState = .failed(message: error.localizedDescription, isEngaged: false) + routeState = .failed( + message: userFacingErrorMessage(for: error), + isEngaged: false + ) throw error } } @@ -232,11 +235,20 @@ final class AudioEqualizerEngine: ObservableObject { recordFailure(error) switch stateBeforeStop { case let .live(targetUID, _): - routeState = .live(targetUID: targetUID, errorMessage: error.localizedDescription) + routeState = .live( + targetUID: targetUID, + errorMessage: userFacingErrorMessage(for: error) + ) case let .failed(_, isEngaged): - routeState = .failed(message: error.localizedDescription, isEngaged: isEngaged) + routeState = .failed( + message: userFacingErrorMessage(for: error), + isEngaged: isEngaged + ) default: - routeState = .failed(message: error.localizedDescription, isEngaged: false) + routeState = .failed( + message: userFacingErrorMessage(for: error), + isEngaged: false + ) } throw error } @@ -288,7 +300,10 @@ final class AudioEqualizerEngine: ObservableObject { routeState = .live(targetUID: targetUID, errorMessage: nil) } catch { recordFailure(error) - routeState = .failed(message: error.localizedDescription, isEngaged: true) + routeState = .failed( + message: userFacingErrorMessage(for: error), + isEngaged: true + ) } } @@ -305,7 +320,10 @@ final class AudioEqualizerEngine: ObservableObject { routeState = .live(targetUID: targetUID, errorMessage: nil) } catch { recordFailure(error) - routeState = .failed(message: error.localizedDescription, isEngaged: true) + routeState = .failed( + message: userFacingErrorMessage(for: error), + isEngaged: true + ) } } @@ -334,13 +352,21 @@ final class AudioEqualizerEngine: ObservableObject { return false } + private func userFacingErrorMessage(for error: Error) -> String? { + if let pluginError = error as? EqualizerHALPluginError { + return pluginError.userFacingDescription + } + return error.localizedDescription + } + private func recordFailure(_ error: Error) { - // The OSStatus, the bundle name and the staged paths are kept off the - // settings pane, so the log is where a bug report picks them up - // (#186). + // The OSStatus, the bundle name and helper diagnostics are kept off the + // settings pane, so the log is where a bug report picks them up (#186). if let pluginError = error as? EqualizerHALPluginError, let details = pluginError.diagnosticDetails { SpotiglassLog.error(.playback, details) + } else if let installError = error as? EqualizerDriverInstallError { + SpotiglassLog.error(.playback, installError.diagnosticDetails) } } diff --git a/Spotiglass/Playback/EqualizerDriverInstallPolicy.swift b/Spotiglass/Playback/EqualizerDriverInstallPolicy.swift new file mode 100644 index 0000000..666fa12 --- /dev/null +++ b/Spotiglass/Playback/EqualizerDriverInstallPolicy.swift @@ -0,0 +1,141 @@ +import Foundation + +/// Version identity for the driver bundle. The release and build components +/// are compared independently so a driver rebuild cannot be mistaken for the +/// same payload merely because its marketing version stayed unchanged. +struct EqualizerDriverVersion: Comparable, Equatable, Sendable, CustomStringConvertible { + let releaseComponents: [Int] + let buildComponents: [Int] + + init?(shortVersion: String, build: String) { + guard let releaseComponents = Self.parseComponents(shortVersion), + let buildComponents = Self.parseComponents(build) + else { return nil } + self.releaseComponents = Self.normalized(releaseComponents) + self.buildComponents = Self.normalized(buildComponents) + } + + var description: String { + let release = releaseComponents.map(String.init).joined(separator: ".") + let build = buildComponents.map(String.init).joined(separator: ".") + return "\(release) (\(build))" + } + + static func < (lhs: Self, rhs: Self) -> Bool { + guard lhs.releaseComponents == rhs.releaseComponents else { + return compare(lhs.releaseComponents, rhs.releaseComponents) + } + return compare(lhs.buildComponents, rhs.buildComponents) + } + + private static func compare(_ lhs: [Int], _ rhs: [Int]) -> Bool { + let count = max(lhs.count, rhs.count) + for index in 0.. [Int]? { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + let parts = value.split(separator: ".", omittingEmptySubsequences: false) + guard !parts.isEmpty, + parts.allSatisfy({ part in + !part.isEmpty + && part.unicodeScalars.allSatisfy { $0.value >= 48 && $0.value <= 57 } + }) + else { return nil } + let components = parts.compactMap { Int($0) } + guard components.count == parts.count else { return nil } + return components + } + + private static func normalized(_ components: [Int]) -> [Int] { + var result = components + while result.count > 1, result.last == 0 { + result.removeLast() + } + return result + } +} + +enum EqualizerDriverState: Equatable { + case missing + case unreadable + case version(EqualizerDriverVersion) +} + +enum EqualizerDriverInstallReason: Equatable { + case missing + case stale + case repair +} + +enum EqualizerDriverInstallDecision: Equatable { + case install(reason: EqualizerDriverInstallReason) + case alreadyCurrent + + var shouldInstall: Bool { + if case .install = self { return true } + return false + } +} + +/// Decides whether the privileged helper needs to touch the system HAL path. +/// A malformed installed bundle is treated as repair work, while an older +/// version is stale. A newer installed driver is safe to keep during a +/// temporary app downgrade. +enum EqualizerDriverInstallPolicy { + static func decision( + bundled: EqualizerDriverVersion, + installed: EqualizerDriverState + ) -> EqualizerDriverInstallDecision { + switch installed { + case .missing: + return .install(reason: .missing) + case .unreadable: + return .install(reason: .repair) + case .version(let installedVersion): + return installedVersion < bundled + ? .install(reason: .stale) + : .alreadyCurrent + } + } +} + +enum EqualizerDriverInstallError: Error, Equatable { + case registrationFailed(status: Int) + case unregistrationFailed(status: Int) + case helperUnavailable(message: String) + case helperRejected(status: Int, message: String) + case helperOperationFailed(status: Int, message: String) + case invalidReply + case invalidRequest + + var diagnosticDetails: String { + switch self { + case .registrationFailed(let status): + return "SMAppService registration failed (status \(status))" + case .unregistrationFailed(let status): + return "SMAppService re-registration could not remove the previous service (status \(status))" + case .helperUnavailable(let message): + return "Spotiglass EQ privileged helper was unavailable: \(message)" + case .helperRejected(let status, let message): + return "Spotiglass EQ privileged helper rejected the request (status \(status)): \(message)" + case .helperOperationFailed(let status, let message): + return "Spotiglass EQ privileged helper failed (status \(status)): \(message)" + case .invalidReply: + return "Spotiglass EQ privileged helper returned an invalid reply" + case .invalidRequest: + return "Spotiglass EQ privileged helper request did not match the installed app bundle" + } + } +} + +enum EqualizerDriverInstallErrorMapper { + static func map(_ error: EqualizerDriverInstallError) -> EqualizerHALPluginError { + .driverInstallationFailed(diagnostic: error.diagnosticDetails) + } +} diff --git a/Spotiglass/Playback/EqualizerHALPluginController.swift b/Spotiglass/Playback/EqualizerHALPluginController.swift index a75ae8c..41785a2 100644 --- a/Spotiglass/Playback/EqualizerHALPluginController.swift +++ b/Spotiglass/Playback/EqualizerHALPluginController.swift @@ -14,10 +14,8 @@ enum EqualizerRouterStatus: Equatable { /// Owns the lifecycle of the bundled `SpotiglassEQDriver.driver` CoreAudio /// AudioServerPlugIn. Responsibilities: /// -/// - Copy the `.driver` bundle from inside `Spotiglass.app` into -/// `~/Library/Audio/Plug-Ins/HAL/` on first enable. -/// - Surface (but never run) the `launchctl kickstart -k system/com.apple.audio.coreaudiod` -/// activation step. +/// - Ask the privileged helper to copy the `.driver` bundle into the system +/// HAL directory when it is missing, stale, or damaged. /// - Switch the system default output device to "Spotiglass EQ" on enable. /// - Restore the previously-active default output on disable. /// - Uninstall (remove the `.driver` from disk) on user request. @@ -26,30 +24,12 @@ enum EqualizerRouterStatus: Equatable { /// pure virtual output device; this controller only ever queries / sets the /// default OUTPUT device, never input. final class EqualizerHALPluginController: @unchecked Sendable, EqualizerSampleRateProviding { - /// System-scope HAL plugins directory. - /// - /// macOS 26's `coreaudiod` scans only `/Library/Audio/Plug-Ins/HAL/`; the - /// older user-scope path (`~/Library/Audio/Plug-Ins/HAL/`) is no longer - /// loaded. Installing here therefore requires `sudo` from the user — the - /// controller writes the path into a staging area and surfaces the copy + - /// coreaudiod kickstart commands rather than running them itself. + /// System-scope HAL plugins directory. macOS 26's `coreaudiod` scans only + /// this path, so writes go through the registered privileged helper. nonisolated static var defaultHALDirectory: URL { URL(fileURLWithPath: "/Library/Audio/Plug-Ins/HAL", isDirectory: true) } - /// Staging directory inside the user's home where the controller copies - /// the embedded driver before prompting the user to move it system-scope. - /// This keeps the GUI process sudo-free while making it trivial for the - /// user to finish the install with a single `sudo cp -R` command. - nonisolated static var stagingDirectory: URL { - FileManager.default - .homeDirectoryForCurrentUser - .appendingPathComponent("Library", isDirectory: true) - .appendingPathComponent("Application Support", isDirectory: true) - .appendingPathComponent("Spotiglass", isDirectory: true) - .appendingPathComponent("staged-driver", isDirectory: true) - } - /// Bundle identifier of the embedded `.driver`. Must match the /// AudioServerPlugIn `Info.plist` key. nonisolated static let driverBundleID = "com.isaaclins.spotiglass.eqdriver" @@ -72,6 +52,7 @@ final class EqualizerHALPluginController: @unchecked Sendable, EqualizerSampleRa private let halDirectory: URL private let embeddedDriverURL: URL? private let fileManager: FileManager + private let driverInstaller: EqualizerDriverInstalling private let outputBackupURL: URL // Keep the CoreAudio route lookups/setter injectable so lifecycle tests can // exercise a running engine without changing the host's real output. @@ -83,6 +64,7 @@ final class EqualizerHALPluginController: @unchecked Sendable, EqualizerSampleRa private let routerStatusURL: URL private let routerStatusReader: () -> EqualizerRouterStatus? private let routerReadinessTimeout: TimeInterval + private let driverLoadTimeout: TimeInterval private let activeSampleRateObservationStarter: ((AudioObjectID) throws -> Void)? private var activeSampleRateListener: AudioObjectPropertyListenerBlock? private var observedSampleRateDeviceID: AudioObjectID? @@ -103,6 +85,7 @@ final class EqualizerHALPluginController: @unchecked Sendable, EqualizerSampleRa fileManager: FileManager = .default, bundle: Bundle = .main, defaultOutputBackupURL: URL = EqualizerHALPluginController.defaultOutputBackupURL, + driverInstaller: EqualizerDriverInstalling? = nil, outputDeviceIDForUID: ((String) -> AudioObjectID?)? = nil, outputDeviceUID: ((AudioObjectID) -> String?)? = nil, defaultOutputDeviceIDProvider: (() throws -> AudioObjectID)? = nil, @@ -111,10 +94,24 @@ final class EqualizerHALPluginController: @unchecked Sendable, EqualizerSampleRa routerStatusURL: URL = EqualizerHALPluginController.routerStatusURL, routerStatusReader: (() -> EqualizerRouterStatus?)? = nil, routerReadinessTimeout: TimeInterval = 2.0, + driverLoadTimeout: TimeInterval = 3.0, activeSampleRateObservationStarter: ((AudioObjectID) throws -> Void)? = nil ) { self.halDirectory = halDirectory self.fileManager = fileManager + let usesSystemHALDirectory = halDirectory.standardizedFileURL + == Self.defaultHALDirectory.standardizedFileURL + self.driverInstaller = driverInstaller ?? { + if usesSystemHALDirectory { + return EqualizerPrivilegedHelperClient( + bundle: bundle, + fileManager: fileManager + ) + } + // Tests use an isolated directory, where a direct copy preserves + // the lifecycle seam without registering a real system daemon. + return EqualizerFileDriverInstaller(fileManager: fileManager) + }() self.outputBackupURL = defaultOutputBackupURL self.outputDeviceIDForUID = outputDeviceIDForUID ?? { uid in AudioDeviceEnumerator.deviceID(forUID: uid) @@ -138,6 +135,7 @@ final class EqualizerHALPluginController: @unchecked Sendable, EqualizerSampleRa Self.readRouterStatus(from: routerStatusURL) } self.routerReadinessTimeout = max(0, routerReadinessTimeout) + self.driverLoadTimeout = max(0, driverLoadTimeout) self.activeSampleRateObservationStarter = activeSampleRateObservationStarter self.embeddedDriverURL = Self.locateEmbeddedDriver(in: bundle) self.previousDefaultOutputID = nil @@ -153,18 +151,17 @@ final class EqualizerHALPluginController: @unchecked Sendable, EqualizerSampleRa // MARK: - Public API - /// Pure install step. Copies the embedded `.driver` into the HAL directory - /// if it isn't already there. Used by ``enable()`` and exposed separately - /// so tests can exercise just the file-copy side-effect without touching - /// the system default output device. NEVER asks for microphone permission. + /// Installs or repairs the embedded `.driver` without changing the system + /// default output device. The system path uses the registered helper; + /// isolated test paths use the injected file installer. func install() throws { try installIfNeeded() } - /// On first enable, installs the `.driver` (if not already in the HAL - /// directory) and routes the system default output to the Spotiglass - /// virtual device. Throws if the bundled driver is missing or the file - /// copy / device lookup fails. NEVER asks for microphone permission. + /// On first enable, installs or repairs the `.driver` and routes the + /// system default output to the Spotiglass virtual device. The helper + /// restarts `coreaudiod`; this method waits briefly for device enumeration + /// before beginning the route. NEVER asks for microphone permission. /// /// `preferredForwardingUID` lets the caller pin the EQRouter's forwarding /// target to a previously-saved device UID, which is what makes the @@ -174,41 +171,51 @@ final class EqualizerHALPluginController: @unchecked Sendable, EqualizerSampleRa @discardableResult func enable(preferredForwardingUID: String? = nil) throws -> String { try installIfNeeded() - if let deviceID = lookupSpotiglassEQDeviceID() { - try captureCurrentDefaultOutput(virtualDeviceID: deviceID) - // Always write the forwarding target so the driver's EQRouter has - // somewhere to send the EQ'd audio. The previous default's UID is - // only relevant when it isn't Spotiglass EQ itself (otherwise the - // EQ would route to itself and recurse forever). - let previousUID: String? = { - if previousDefaultOutputID == deviceID { return nil } - if let uid = previousDefaultOutputUID, !uid.isEmpty { return uid } - return previousDefaultOutputID.flatMap { outputDeviceUID($0) } - }() - let targetUID = Self.resolveForwardingTargetUID( - preferred: preferredForwardingUID, - previousUID: previousUID - ) - prepareRouterReadiness(for: targetUID) - Self.writeForwardingTarget(uid: targetUID) - if let activeSampleRateObservationStarter { - try activeSampleRateObservationStarter(deviceID) - } else { - try beginActiveSampleRateObservation(for: deviceID) - } - do { - try setDefaultOutputDevice(to: deviceID) - try waitForRouterReadiness(targetUID: targetUID) - } catch { - removeActiveSampleRateObservation() - throw error + let deviceID: AudioObjectID + if let loadedDeviceID = waitForSpotiglassEQDevice() { + deviceID = loadedDeviceID + } else { + // A current bundle can still be absent from coreaudiod after a + // daemon crash. Re-copying it asks the helper to restart the daemon + // and gives the system a repair path without user intervention. + try installBundledDriver() + guard let repairedDeviceID = waitForSpotiglassEQDevice() else { + throw EqualizerHALPluginError.driverNotLoadedYet( + installedPath: installedDriverURL.path + ) } - return targetUID + deviceID = repairedDeviceID + } + + try captureCurrentDefaultOutput(virtualDeviceID: deviceID) + // Always write the forwarding target so the driver's EQRouter has + // somewhere to send the EQ'd audio. The previous default's UID is + // only relevant when it isn't Spotiglass EQ itself (otherwise the + // EQ would route to itself and recurse forever). + let previousUID: String? = { + if previousDefaultOutputID == deviceID { return nil } + if let uid = previousDefaultOutputUID, !uid.isEmpty { return uid } + return previousDefaultOutputID.flatMap { outputDeviceUID($0) } + }() + let targetUID = Self.resolveForwardingTargetUID( + preferred: preferredForwardingUID, + previousUID: previousUID + ) + prepareRouterReadiness(for: targetUID) + Self.writeForwardingTarget(uid: targetUID) + if let activeSampleRateObservationStarter { + try activeSampleRateObservationStarter(deviceID) } else { - throw EqualizerHALPluginError.driverNotLoadedYet( - installedPath: installedDriverURL.path - ) + try beginActiveSampleRateObservation(for: deviceID) + } + do { + try setDefaultOutputDevice(to: deviceID) + try waitForRouterReadiness(targetUID: targetUID) + } catch { + removeActiveSampleRateObservation() + throw error } + return targetUID } /// Fallback UID for the EQRouter's forwarding target when we don't have @@ -385,9 +392,9 @@ final class EqualizerHALPluginController: @unchecked Sendable, EqualizerSampleRa return trimmed.isEmpty ? nil : trimmed } - /// Removes the bundled `.driver` from `~/Library/Audio/Plug-Ins/HAL/`. - /// coreaudiod will keep the device visible until it next reloads its HAL - /// directory (kickstart or log-out/in). + /// Removes the bundled `.driver` from `/Library/Audio/Plug-Ins/HAL/`. + /// This maintenance hook is separate from enable, which uses the helper + /// because the system directory is root-owned. func uninstall() throws { let url = installedDriverURL if fileManager.fileExists(atPath: url.path) { @@ -406,46 +413,73 @@ final class EqualizerHALPluginController: @unchecked Sendable, EqualizerSampleRa // MARK: - Install internals private func installIfNeeded() throws { - let destination = installedDriverURL - // If a copy already exists in the HAL directory, trust it. The system - // scope (/Library/Audio/Plug-Ins/HAL) is root-owned on macOS 26, so the - // unprivileged app process cannot replace it anyway; the install must - // happen out-of-band via `sudo cp -pR`. Re-running the copy with a - // writable temp dir is still useful for tests, which is what the - // create-if-missing branch below covers. - if fileManager.fileExists(atPath: destination.path) { - return + guard let source = embeddedDriverURL else { + throw EqualizerHALPluginError.embeddedDriverMissing } + let bundledState = Self.driverState(at: source, fileManager: fileManager) + guard case let .version(bundledVersion) = bundledState else { + throw EqualizerHALPluginError.embeddedDriverMetadataMissing + } + + let decision = EqualizerDriverInstallPolicy.decision( + bundled: bundledVersion, + installed: Self.driverState(at: installedDriverURL, fileManager: fileManager) + ) + guard decision.shouldInstall else { return } + try installBundledDriver() + } + + private func installBundledDriver() throws { guard let source = embeddedDriverURL else { throw EqualizerHALPluginError.embeddedDriverMissing } do { - if !fileManager.fileExists(atPath: halDirectory.path) { - try fileManager.createDirectory(at: halDirectory, withIntermediateDirectories: true) - } - try fileManager.copyItem(at: source, to: destination) + try driverInstaller.installDriver( + sourceURL: source, + destinationURL: installedDriverURL + ) + } catch let error as EqualizerDriverInstallError { + throw EqualizerDriverInstallErrorMapper.map(error) } catch { - // App-scope process cannot write to /Library/Audio/Plug-Ins/HAL. - // Stage the bundle in user-scope and surface a one-shot sudo install - // command via ``requiresSudoInstall`` so the UI can render it. - let staged = try stageEmbeddedDriver(from: source) - throw EqualizerHALPluginError.requiresSudoInstall( - stagedPath: staged.path, - destinationPath: destination.path + throw EqualizerHALPluginError.driverInstallationFailed( + diagnostic: error.localizedDescription ) } } - private func stageEmbeddedDriver(from source: URL) throws -> URL { - let stagedBundle = Self.stagingDirectory - .appendingPathComponent(Self.driverBundleName, isDirectory: true) - try? fileManager.removeItem(at: stagedBundle) - try fileManager.createDirectory( - at: Self.stagingDirectory, - withIntermediateDirectories: true - ) - try fileManager.copyItem(at: source, to: stagedBundle) - return stagedBundle + nonisolated static func driverState( + at url: URL, + fileManager: FileManager = .default + ) -> EqualizerDriverState { + guard fileManager.fileExists(atPath: url.path) else { return .missing } + let infoURL = url.appendingPathComponent("Contents/Info.plist", isDirectory: false) + guard let data = try? Data(contentsOf: infoURL), + let propertyList = try? PropertyListSerialization.propertyList( + from: data, + options: [], + format: nil + ), + let dictionary = propertyList as? [String: Any], + let shortVersion = dictionary["CFBundleShortVersionString"] as? String, + let build = dictionary["CFBundleVersion"] as? String, + let version = EqualizerDriverVersion( + shortVersion: shortVersion, + build: build + ) + else { return .unreadable } + return .version(version) + } + + private func waitForSpotiglassEQDevice() -> AudioObjectID? { + let deadline = Date().addingTimeInterval(driverLoadTimeout) + repeat { + if let deviceID = lookupSpotiglassEQDeviceID() { + return deviceID + } + if Date() >= deadline { break } + Thread.sleep(forTimeInterval: min(0.05, max(0, deadline.timeIntervalSinceNow))) + } while true + return nil } private static func locateEmbeddedDriver(in bundle: Bundle) -> URL? { @@ -736,6 +770,8 @@ final class EqualizerHALPluginController: @unchecked Sendable, EqualizerSampleRa enum EqualizerHALPluginError: LocalizedError { case embeddedDriverMissing + case embeddedDriverMetadataMissing + case driverInstallationFailed(diagnostic: String) case driverNotLoadedYet(installedPath: String) case coreAudioStatus(OSStatus) case outputDeviceUIDUnavailable @@ -744,39 +780,54 @@ enum EqualizerHALPluginError: LocalizedError { case previousOutputRestoreFailed(underlying: Error) case routerTargetOpenFailed(targetUID: String, reasonCode: Int) case routerReadinessTimedOut(targetUID: String) - case requiresSudoInstall(stagedPath: String, destinationPath: String) - // These reach the user: EqualizerSettingsView assigns localizedDescription - // to lastError and to the save sheet. So they are sentences from the - // catalog, and the OSStatus, the bundle name and the paths live in - // diagnosticDetails instead (#186). + /// Driver installation failures are written to the log, not the settings + /// pane. The operating-system authorization prompt is the only user-facing + /// part of installing the system plug-in. + var isUserVisible: Bool { + switch self { + case .embeddedDriverMissing, + .embeddedDriverMetadataMissing, + .driverInstallationFailed, + .driverNotLoadedYet: + false + case .coreAudioStatus, + .outputDeviceUIDUnavailable, + .previousOutputBackupMissing, + .previousOutputDeviceUnavailable, + .previousOutputRestoreFailed, + .routerTargetOpenFailed, + .routerReadinessTimedOut: + true + } + } + + var userFacingDescription: String? { + guard isUserVisible else { return nil } + return errorDescription + } + var errorDescription: String? { switch self { - case .embeddedDriverMissing: - return SpotiglassL10n.string("eq.error.embeddedDriverMissing") - case .driverNotLoadedYet: - return SpotiglassL10n.string("eq.error.driverNotLoadedYet") + case .embeddedDriverMissing, + .embeddedDriverMetadataMissing, + .driverInstallationFailed, + .driverNotLoadedYet: + nil case .coreAudioStatus: - return SpotiglassL10n.string("eq.error.coreAudioStatus") + SpotiglassL10n.string("eq.error.coreAudioStatus") case .outputDeviceUIDUnavailable: - return SpotiglassL10n.string("eq.error.outputDeviceUIDUnavailable") + SpotiglassL10n.string("eq.error.outputDeviceUIDUnavailable") case .previousOutputBackupMissing: - return SpotiglassL10n.string("eq.error.previousOutputBackupMissing") + SpotiglassL10n.string("eq.error.previousOutputBackupMissing") case .previousOutputDeviceUnavailable: - return SpotiglassL10n.string("eq.error.previousOutputDeviceUnavailable") + SpotiglassL10n.string("eq.error.previousOutputDeviceUnavailable") case .previousOutputRestoreFailed: - return SpotiglassL10n.string("eq.error.previousOutputRestoreFailed") + SpotiglassL10n.string("eq.error.previousOutputRestoreFailed") case .routerTargetOpenFailed: - return SpotiglassL10n.string("eq.error.routerTargetOpenFailed") + SpotiglassL10n.string("eq.error.routerTargetOpenFailed") case .routerReadinessTimedOut: - return SpotiglassL10n.string("eq.error.routerReadinessTimedOut") - case let .requiresSudoInstall(staged, destination): - // The commands stay in the sentence, because running them is the - // action being asked for. - return SpotiglassL10n.format( - "eq.error.requiresSudoInstall", - Self.installCommands(staged: staged, destination: destination) - ) + SpotiglassL10n.string("eq.error.routerReadinessTimedOut") } } @@ -786,13 +837,12 @@ enum EqualizerHALPluginError: LocalizedError { switch self { case .embeddedDriverMissing: return "missing bundle: SpotiglassEQDriver.driver" + case .embeddedDriverMetadataMissing: + return "bundled driver metadata is missing or invalid" + case let .driverInstallationFailed(diagnostic): + return diagnostic case let .driverNotLoadedYet(installedPath): - return """ - installed driver: \(installedPath) - coreaudiod has not picked it up yet; a one-time activation, which \ - Spotiglass never performs for you: - sudo launchctl kickstart -k system/com.apple.audio.coreaudiod - """ + return "installed driver: \(installedPath)\ncoreaudiod did not enumerate the driver after the helper restart" case let .coreAudioStatus(status): return "CoreAudio OSStatus \(status) while routing the default output device" case .outputDeviceUIDUnavailable: @@ -809,21 +859,8 @@ enum EqualizerHALPluginError: LocalizedError { return "EQRouter could not open target UID \(targetUID) (reason \(reasonCode))" case let .routerReadinessTimedOut(targetUID): return "EQRouter did not report readiness for target UID \(targetUID)" - case let .requiresSudoInstall(staged, destination): - return "staged: \(staged)\ndestination: \(destination)" } } - - private static func installCommands(staged: String, destination: String) -> String { - let folder = destination.replacingOccurrences( - of: "/SpotiglassEQDriver.driver", - with: "/" - ) - return """ - sudo cp -pR "\(staged)" "\(folder)" - sudo killall coreaudiod - """ - } } /// Lightweight AudioObject enumerator used by the controller. Only enumerates diff --git a/Spotiglass/Playback/EqualizerPrivilegedHelperClient.swift b/Spotiglass/Playback/EqualizerPrivilegedHelperClient.swift new file mode 100644 index 0000000..3fe3fca --- /dev/null +++ b/Spotiglass/Playback/EqualizerPrivilegedHelperClient.swift @@ -0,0 +1,281 @@ +import Foundation +import ServiceManagement + +protocol EqualizerDriverInstalling { + func installDriver(sourceURL: URL, destinationURL: URL) throws +} + +@objc protocol EqualizerPrivilegedHelperProtocol { + func installDriver( + from sourcePath: String, + to destinationPath: String, + withReply reply: @escaping (NSDictionary) -> Void + ) +} + +/// Registers and talks to the root LaunchDaemon that owns the system HAL path. +/// The app only sends paths inside its own signed bundle; the helper validates +/// those paths again before it performs any privileged filesystem operation. +final class EqualizerPrivilegedHelperClient: @unchecked Sendable, EqualizerDriverInstalling { + private let bundle: Bundle + private let fileManager: FileManager + private let service: SMAppService + + init( + bundle: Bundle = .main, + fileManager: FileManager = .default, + service: SMAppService? = nil + ) { + self.bundle = bundle + self.fileManager = fileManager + self.service = + service + ?? SMAppService.daemon( + plistName: EqualizerPrivilegedHelperIdentity.plistName + ) + } + + func installDriver(sourceURL: URL, destinationURL: URL) throws { + let source = sourceURL.standardizedFileURL + let destination = destinationURL.standardizedFileURL + guard isBundledDriverURL(source), destination == installedDriverURL else { + throw EqualizerDriverInstallError.invalidRequest + } + + try ensureRegistered() + do { + try requestInstall(sourceURL: source, destinationURL: destination) + } catch let error as EqualizerDriverInstallError { + guard shouldRetryAfter(error) else { throw error } + try reRegister() + try requestInstall(sourceURL: source, destinationURL: destination) + } + writeHelperVersionMarker() + } + + private var installedDriverURL: URL { + EqualizerPrivilegedHelperIdentity.systemHALDirectory + .appendingPathComponent(EqualizerPrivilegedHelperIdentity.driverBundleName, isDirectory: true) + } + + private func isBundledDriverURL(_ sourceURL: URL) -> Bool { + let bundleURL = bundle.bundleURL.standardizedFileURL + let candidates = [ + bundleURL + .appendingPathComponent(EqualizerPrivilegedHelperIdentity.driverRelativePath, isDirectory: true) + .appendingPathComponent(EqualizerPrivilegedHelperIdentity.driverBundleName, isDirectory: true), + bundleURL + .appendingPathComponent(EqualizerPrivilegedHelperIdentity.resourceDriverRelativePath, isDirectory: true) + .appendingPathComponent(EqualizerPrivilegedHelperIdentity.driverBundleName, isDirectory: true), + ] + return candidates.contains(sourceURL) + } + + private func ensureRegistered() throws { + switch service.status { + case .enabled: + if !helperVersionMarkerMatches() { + try reRegister() + } + case .notRegistered, .requiresApproval: + try register() + case .notFound: + throw EqualizerDriverInstallError.registrationFailed(status: service.status.rawValue) + @unknown default: + throw EqualizerDriverInstallError.registrationFailed(status: service.status.rawValue) + } + } + + private func register() throws { + do { + try service.register() + } catch { + throw EqualizerDriverInstallError.registrationFailed( + status: (error as NSError).code + ) + } + } + + private func reRegister() throws { + do { + try service.unregister() + } catch { + throw EqualizerDriverInstallError.unregistrationFailed( + status: (error as NSError).code + ) + } + try register() + } + + private func shouldRetryAfter(_ error: EqualizerDriverInstallError) -> Bool { + switch error { + case .helperUnavailable, .invalidReply: + true + case .registrationFailed, + .unregistrationFailed, + .helperRejected, + .helperOperationFailed, + .invalidRequest: + false + } + } + + private struct ReplyTransportError: Error { + let message: String + } + + private final class ReplyBox: @unchecked Sendable { + private let lock = NSLock() + private var result: Result? + + func store(_ result: Result) { + lock.lock() + if self.result == nil { + self.result = result + } + lock.unlock() + } + + func load() -> Result? { + lock.lock() + let result = self.result + lock.unlock() + return result + } + } + + private func requestInstall(sourceURL: URL, destinationURL: URL) throws { + let connection = NSXPCConnection( + machServiceName: EqualizerPrivilegedHelperIdentity.machServiceName, + options: .privileged + ) + connection.remoteObjectInterface = NSXPCInterface( + with: EqualizerPrivilegedHelperProtocol.self + ) + connection.setCodeSigningRequirement( + EqualizerPrivilegedHelperIdentity.helperRequirement(for: bundle) + ) + + let completion = DispatchSemaphore(value: 0) + let replyBox = ReplyBox() + connection.interruptionHandler = { + replyBox.store(.failure(ReplyTransportError(message: "connection interrupted"))) + completion.signal() + } + connection.invalidationHandler = { + replyBox.store(.failure(ReplyTransportError(message: "connection invalidated"))) + completion.signal() + } + connection.resume() + + guard + let proxy = connection.remoteObjectProxyWithErrorHandler({ error in + replyBox.store(.failure(ReplyTransportError(message: error.localizedDescription))) + completion.signal() + }) as? EqualizerPrivilegedHelperProtocol + else { + connection.invalidate() + throw EqualizerDriverInstallError.helperUnavailable( + message: "the helper proxy could not be created" + ) + } + + proxy.installDriver( + from: sourceURL.path, + to: destinationURL.path + ) { reply in + replyBox.store(.success(reply)) + completion.signal() + } + + let waitResult = completion.wait(timeout: .now() + 30) + connection.invalidate() + guard waitResult == .success else { + throw EqualizerDriverInstallError.helperUnavailable( + message: "the helper did not reply within 30 seconds" + ) + } + guard let result = replyBox.load() else { + throw EqualizerDriverInstallError.invalidReply + } + switch result { + case .failure(let error): + throw EqualizerDriverInstallError.helperUnavailable(message: error.message) + case .success(let reply): + guard let status = reply["status"] as? NSNumber else { + throw EqualizerDriverInstallError.invalidReply + } + guard status.intValue == 0 else { + let message = reply["message"] as? String ?? "unknown helper failure" + if status.intValue == 64 { + throw EqualizerDriverInstallError.helperRejected( + status: status.intValue, + message: message + ) + } + throw EqualizerDriverInstallError.helperOperationFailed( + status: status.intValue, + message: message + ) + } + } + } + + private func helperVersionMarkerMatches() -> Bool { + guard let expectedVersion = helperVersion, + let marker = try? String( + contentsOf: EqualizerPrivilegedHelperIdentity.helperVersionURL, + encoding: .utf8 + ) + else { return false } + return marker.trimmingCharacters(in: .whitespacesAndNewlines) == expectedVersion + } + + private func writeHelperVersionMarker() { + guard let helperVersion else { return } + let markerURL = EqualizerPrivilegedHelperIdentity.helperVersionURL + do { + try fileManager.createDirectory( + at: markerURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try (helperVersion + "\n").write( + to: markerURL, + atomically: true, + encoding: .utf8 + ) + } catch { + // The marker only avoids a redundant re-registration after the + // next launch. A failed write must not undo a completed install. + SpotiglassLog.error( + .playback, + "Could not record the equalizer helper version: \(error.localizedDescription)" + ) + } + } + + private var helperVersion: String? { + bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String + } +} + +struct EqualizerFileDriverInstaller: EqualizerDriverInstalling { + let fileManager: FileManager + + func installDriver(sourceURL: URL, destinationURL: URL) throws { + let parent = destinationURL.deletingLastPathComponent() + try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) + + let temporaryURL = parent.appendingPathComponent( + ".\(destinationURL.lastPathComponent).install-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? fileManager.removeItem(at: temporaryURL) } + + try fileManager.copyItem(at: sourceURL, to: temporaryURL) + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.removeItem(at: destinationURL) + } + try fileManager.moveItem(at: temporaryURL, to: destinationURL) + } +} diff --git a/Spotiglass/Playback/EqualizerPrivilegedHelperIdentity.swift b/Spotiglass/Playback/EqualizerPrivilegedHelperIdentity.swift new file mode 100644 index 0000000..6e61299 --- /dev/null +++ b/Spotiglass/Playback/EqualizerPrivilegedHelperIdentity.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Shared identity and requirement builder for the app, helper, and driver. +/// Keeping the signer policy here prevents one side of the trust boundary from +/// silently accepting a broader certificate set than the other. +enum EqualizerPrivilegedHelperIdentity { + static let machServiceName = "com.isaaclins.spotiglass.eqprivilegedhelper" + static let plistName = "com.isaaclins.spotiglass.eqprivilegedhelper.plist" + static let applicationBundleIdentifier = "com.isaaclins.spotiglass" + static let helperBundleIdentifier = "com.isaaclins.spotiglass.eqprivilegedhelper" + static let driverBundleIdentifier = "com.isaaclins.spotiglass.eqdriver" + static let driverBundleName = "SpotiglassEQDriver.driver" + static let driverRelativePath = "Contents/Library/Audio/Plug-Ins/HAL" + static let resourceDriverRelativePath = "Contents/Resources" + static let systemHALDirectory = URL(fileURLWithPath: "/Library/Audio/Plug-Ins/HAL", isDirectory: true) + static let localCertificateCommonName = "Spotiglass Local Dev" + static let teamIdentifierInfoKey = "SpotiglassCodeSigningTeamIdentifier" + + static var helperRequirement: String { + requirement(for: helperBundleIdentifier) + } + + static var clientRequirement: String { + requirement(for: applicationBundleIdentifier) + } + + static var driverRequirement: String { + requirement(for: driverBundleIdentifier) + } + + static func helperRequirement(for bundle: Bundle) -> String { + requirement(for: helperBundleIdentifier, bundle: bundle) + } + + /// Builds the pure signer requirement used by all three code objects. + /// Developer ID and Apple Development certificates identify the team via + /// their leaf OU; the local development certificate has no team OU, so its + /// stable common name is the explicit development-only alternative. + static func requirement( + for identifier: String, + teamIdentifier: String, + localCertificateCommonName: String = Self.localCertificateCommonName + ) -> String { + "((anchor apple generic and certificate leaf[subject.OU] = \"\(teamIdentifier)\") or certificate leaf[subject.CN] = \"\(localCertificateCommonName)\") and identifier \"\(identifier)\"" + } + + private static func requirement(for identifier: String, bundle: Bundle = .main) -> String { + requirement( + for: identifier, + teamIdentifier: teamIdentifier(from: bundle) + ) + } + + private static func teamIdentifier(from bundle: Bundle) -> String { + let configured = bundle.object(forInfoDictionaryKey: teamIdentifierInfoKey) as? String + let trimmed = configured?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? "UNCONFIGURED" : trimmed + } + + static var helperVersionURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library", isDirectory: true) + .appendingPathComponent("Application Support", isDirectory: true) + .appendingPathComponent("Spotiglass", isDirectory: true) + .appendingPathComponent("eq-privileged-helper.version", isDirectory: false) + } +} diff --git a/SpotiglassEQDriver/README.md b/SpotiglassEQDriver/README.md index 31a1233..fc126e9 100644 --- a/SpotiglassEQDriver/README.md +++ b/SpotiglassEQDriver/README.md @@ -26,10 +26,12 @@ Two paths: 2. **`./build-driver.sh`** alone — produces a standalone driver bundle at `../build/SpotiglassEQDriver.driver`. Useful for inspecting the Mach-O. -A proper Xcode target (with code-sign-on-copy and a Run scheme) is -documented in `docs/equalizer-xcode-target.md` as the recommended next -step once Developer ID signing is wired up. `coreaudiod` on macOS 26 -refuses to load `.driver` bundles signed only ad-hoc. +The application now contains a separate Xcode target for +`SpotiglassEQPrivilegedHelper`. That helper is registered through +`SMAppService` and copies this bundle into the system HAL directory when the +user enables EQ. The driver itself remains a standalone target for now, and +`make embed-driver` places it inside the app before a signed manual check. +`coreaudiod` on macOS 26 refuses to load `.driver` bundles signed only ad-hoc. ## Status of property handlers diff --git a/SpotiglassEQDriver/build-driver.sh b/SpotiglassEQDriver/build-driver.sh index 23b9e6e..82f32c6 100755 --- a/SpotiglassEQDriver/build-driver.sh +++ b/SpotiglassEQDriver/build-driver.sh @@ -24,6 +24,12 @@ SRC="." OUT_ROOT="../build/SpotiglassEQDriver.driver" OUT_BIN="$OUT_ROOT/Contents/MacOS/SpotiglassEQDriver" OUT_PLIST="$OUT_ROOT/Contents/Info.plist" +# Release builds pass the app's versions so driver upgrades are detected even +# when the driver target is compiled by this standalone script. +DRIVER_MARKETING_VERSION="${DRIVER_MARKETING_VERSION:-0.1.0}" +DRIVER_BUILD_VERSION="${DRIVER_BUILD_VERSION:-1}" +DRIVER_CODESIGN_IDENTITY="${DRIVER_CODESIGN_IDENTITY:-}" +DRIVER_CODESIGN_KEYCHAIN="${DRIVER_CODESIGN_KEYCHAIN:-}" SDK="$(xcrun --sdk macosx --show-sdk-path)" DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-13.0}" @@ -92,9 +98,20 @@ xcrun clang++ "${LINK_FLAGS[@]}" \ echo "==> copying Info.plist" cp "$SRC/Info.plist" "$OUT_PLIST" +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $DRIVER_MARKETING_VERSION" "$OUT_PLIST" +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $DRIVER_BUILD_VERSION" "$OUT_PLIST" -echo "==> ad-hoc codesigning (Developer ID needed for coreaudiod to load on macOS 26)" -codesign --force --sign - --timestamp=none "$OUT_ROOT" +if [ -n "$DRIVER_CODESIGN_IDENTITY" ]; then + echo "==> codesigning driver with $DRIVER_CODESIGN_IDENTITY" + codesign_args=(--force --sign "$DRIVER_CODESIGN_IDENTITY" --timestamp=none) + if [ -n "$DRIVER_CODESIGN_KEYCHAIN" ]; then + codesign_args+=(--keychain "$DRIVER_CODESIGN_KEYCHAIN") + fi + codesign "${codesign_args[@]}" "$OUT_ROOT" +else + echo "==> ad-hoc codesigning (Developer ID or local identity needed for coreaudiod)" + codesign --force --sign - --timestamp=none "$OUT_ROOT" +fi # Clean up object files so the source tree stays git-clean. rm -f "$SRC"/*.o @@ -103,8 +120,12 @@ echo echo "OK: $OUT_ROOT" echo " $(file "$OUT_BIN")" echo -echo "To install: cp -R '$OUT_ROOT' ~/Library/Audio/Plug-Ins/HAL/" -echo "Then: sudo launchctl kickstart -k system/com.apple.audio.coreaudiod" +echo "Embed this bundle with: make embed-driver" +echo "A signed Spotiglass build installs it through the registered privileged helper." echo -echo "NOTE: ad-hoc signing is not enough for coreaudiod on macOS 26 (kAudioHardwareIllegalOperationError)." -echo "Re-sign with a Developer ID identity before installing on a production system." +if [ -z "$DRIVER_CODESIGN_IDENTITY" ]; then + echo "NOTE: ad-hoc signing is not enough for coreaudiod on macOS 26 (kAudioHardwareIllegalOperationError)." + echo "Re-sign with a Developer ID identity before installing on a production system." +else + echo "Driver signed with $DRIVER_CODESIGN_IDENTITY." +fi diff --git a/SpotiglassEQPrivilegedHelper/Info.plist b/SpotiglassEQPrivilegedHelper/Info.plist new file mode 100644 index 0000000..60d1d4e --- /dev/null +++ b/SpotiglassEQPrivilegedHelper/Info.plist @@ -0,0 +1,20 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + SpotiglassEQPrivilegedHelper + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + SpotiglassCodeSigningTeamIdentifier + $(DEVELOPMENT_TEAM) + + diff --git a/SpotiglassEQPrivilegedHelper/com.isaaclins.spotiglass.eqprivilegedhelper.plist b/SpotiglassEQPrivilegedHelper/com.isaaclins.spotiglass.eqprivilegedhelper.plist new file mode 100644 index 0000000..288cf21 --- /dev/null +++ b/SpotiglassEQPrivilegedHelper/com.isaaclins.spotiglass.eqprivilegedhelper.plist @@ -0,0 +1,17 @@ + + + + + Label + com.isaaclins.spotiglass.eqprivilegedhelper + BundleProgram + Contents/Library/PrivilegedHelperTools/SpotiglassEQPrivilegedHelper + MachServices + + com.isaaclins.spotiglass.eqprivilegedhelper + + + ThrottleInterval + 10 + + diff --git a/SpotiglassEQPrivilegedHelper/main.swift b/SpotiglassEQPrivilegedHelper/main.swift new file mode 100644 index 0000000..df23a41 --- /dev/null +++ b/SpotiglassEQPrivilegedHelper/main.swift @@ -0,0 +1,238 @@ +import Darwin +import Foundation +import Security + +@objc private protocol EqualizerPrivilegedHelperProtocol { + func installDriver( + from sourcePath: String, + to destinationPath: String, + withReply reply: @escaping (NSDictionary) -> Void + ) +} + +private enum HelperOperationError: Error, CustomStringConvertible { + case invalidRequest + case sourceMissing + case copyFailed(errno: Int32) + case restartFailed(status: Int32) + case restartCouldNotStart(String) + + var description: String { + switch self { + case .invalidRequest: + return "the request did not identify this app's bundled driver" + case .sourceMissing: + return "the bundled driver is missing" + case .copyFailed(let errno): + return "copyfile failed with errno \(errno)" + case .restartFailed(let status): + return "coreaudiod restart returned status \(status)" + case .restartCouldNotStart(let message): + return "coreaudiod restart could not start: \(message)" + } + } +} + +private final class EqualizerPrivilegedService: NSObject, EqualizerPrivilegedHelperProtocol { + private let appBundleURL: URL + private let fileManager = FileManager.default + + init?(executableURL: URL) { + var bundleURL = executableURL.standardizedFileURL + for _ in 0..<4 { + bundleURL.deleteLastPathComponent() + } + guard bundleURL.pathExtension == "app" else { return nil } + appBundleURL = bundleURL + } + + func installDriver( + from sourcePath: String, + to destinationPath: String, + withReply reply: @escaping (NSDictionary) -> Void + ) { + do { + try install( + sourceURL: URL(fileURLWithPath: sourcePath), + destinationURL: URL(fileURLWithPath: destinationPath) + ) + reply(Self.response(status: 0, message: "")) + } catch { + let status: Int32 = error is HelperOperationError && isRejected(error) ? 64 : 74 + reply(Self.response(status: status, message: String(describing: error))) + } + } + + private func install(sourceURL: URL, destinationURL: URL) throws { + guard isExpectedSource(sourceURL), destinationURL == expectedDestinationURL else { + throw HelperOperationError.invalidRequest + } + guard fileManager.fileExists(atPath: sourceURL.path) else { + throw HelperOperationError.sourceMissing + } + guard hasValidDriverSignature(at: sourceURL) else { + // Path checks prevent arbitrary filesystem writes. Signature + // validation prevents a modified app bundle from becoming a + // root-loaded CoreAudio plug-in. + throw HelperOperationError.invalidRequest + } + + let parentURL = destinationURL.deletingLastPathComponent() + try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true) + let temporaryURL = parentURL.appendingPathComponent( + ".\(destinationURL.lastPathComponent).install-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? fileManager.removeItem(at: temporaryURL) } + + // COPYFILE_ALL retains the signed bundle's metadata, including the + // executable mtime that CoreAudio checks against its code signature. + let flags = copyfile_flags_t( + COPYFILE_ALL | COPYFILE_RECURSIVE | COPYFILE_NOFOLLOW_SRC | COPYFILE_NOFOLLOW_DST + ) + guard copyfile(sourceURL.path, temporaryURL.path, nil, flags) == 0 else { + throw HelperOperationError.copyFailed(errno: errno) + } + + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.removeItem(at: destinationURL) + } + try fileManager.moveItem(at: temporaryURL, to: destinationURL) + try restartCoreAudio() + } + + private func hasValidDriverSignature(at url: URL) -> Bool { + var staticCode: SecStaticCode? + guard SecStaticCodeCreateWithPath(url as CFURL, [], &staticCode) == errSecSuccess, + let staticCode + else { return false } + + var requirement: SecRequirement? + guard + SecRequirementCreateWithString( + EqualizerPrivilegedHelperIdentity.driverRequirement as CFString, + [], + &requirement + ) == errSecSuccess, + let requirement + else { return false } + + return SecStaticCodeCheckValidity(staticCode, [], requirement) == errSecSuccess + } + + private var expectedDestinationURL: URL { + EqualizerPrivilegedHelperIdentity.systemHALDirectory + .appendingPathComponent(EqualizerPrivilegedHelperIdentity.driverBundleName, isDirectory: true) + .standardizedFileURL + } + + private func isExpectedSource(_ sourceURL: URL) -> Bool { + let bundleURL = appBundleURL.standardizedFileURL + let candidates = [ + bundleURL + .appendingPathComponent(EqualizerPrivilegedHelperIdentity.driverRelativePath, isDirectory: true) + .appendingPathComponent(EqualizerPrivilegedHelperIdentity.driverBundleName, isDirectory: true), + bundleURL + .appendingPathComponent(EqualizerPrivilegedHelperIdentity.resourceDriverRelativePath, isDirectory: true) + .appendingPathComponent(EqualizerPrivilegedHelperIdentity.driverBundleName, isDirectory: true), + ] + return candidates.contains(sourceURL.standardizedFileURL) + } + + private func restartCoreAudio() throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/killall") + process.arguments = ["coreaudiod"] + process.standardOutput = Pipe() + process.standardError = Pipe() + do { + try process.run() + } catch { + throw HelperOperationError.restartCouldNotStart(error.localizedDescription) + } + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw HelperOperationError.restartFailed(status: process.terminationStatus) + } + } + + private func isRejected(_ error: Error) -> Bool { + guard let helperError = error as? HelperOperationError else { return false } + if case .invalidRequest = helperError { return true } + return false + } + + private static func response(status: Int32, message: String) -> NSDictionary { + [ + "status": NSNumber(value: status), + "message": message, + ] as NSDictionary + } +} + +private final class EqualizerConnectionDelegate: NSObject, NSXPCListenerDelegate { + private var services: [ObjectIdentifier: EqualizerPrivilegedService] = [:] + private let lock = NSLock() + private let service: EqualizerPrivilegedService? + + init(executableURL: URL) { + service = EqualizerPrivilegedService(executableURL: executableURL) + } + + func listener( + _ listener: NSXPCListener, + shouldAcceptNewConnection newConnection: NSXPCConnection + ) -> Bool { + guard newConnection.effectiveUserIdentifier != 0, + let service + else { return false } + + newConnection.setCodeSigningRequirement( + EqualizerPrivilegedHelperIdentity.clientRequirement + ) + newConnection.exportedInterface = NSXPCInterface( + with: EqualizerPrivilegedHelperProtocol.self + ) + newConnection.exportedObject = service + let identifier = ObjectIdentifier(newConnection) + store(service, for: identifier) + newConnection.invalidationHandler = { [weak self] in + self?.removeService(for: identifier) + } + newConnection.interruptionHandler = { [weak self] in + self?.removeService(for: identifier) + } + newConnection.resume() + return true + } + + private func store(_ service: EqualizerPrivilegedService, for identifier: ObjectIdentifier) { + lock.lock() + services[identifier] = service + lock.unlock() + } + + private func removeService(for identifier: ObjectIdentifier) { + lock.lock() + services.removeValue(forKey: identifier) + lock.unlock() + } +} + +private let executableURL: URL = { + let argument = CommandLine.arguments.first ?? "" + if argument.hasPrefix("/") { + return URL(fileURLWithPath: argument) + } + return URL( + fileURLWithPath: argument, + relativeTo: URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + ) +}() +private let connectionDelegate = EqualizerConnectionDelegate(executableURL: executableURL) +private let listener = NSXPCListener( + machServiceName: EqualizerPrivilegedHelperIdentity.machServiceName +) +listener.delegate = connectionDelegate +listener.resume() +RunLoop.current.run() diff --git a/SpotiglassTests/EqualizerDriverInstallPolicyTests.swift b/SpotiglassTests/EqualizerDriverInstallPolicyTests.swift new file mode 100644 index 0000000..02e60f5 --- /dev/null +++ b/SpotiglassTests/EqualizerDriverInstallPolicyTests.swift @@ -0,0 +1,132 @@ +import XCTest + +@testable import Spotiglass + +final class EqualizerDriverInstallPolicyTests: XCTestCase { + private let firstRelease = EqualizerDriverVersion(shortVersion: "0.1.0", build: "1")! + + func testDriverVersionsCompareReleaseBeforeBuild() { + let rebuilt = EqualizerDriverVersion(shortVersion: "0.1.0", build: "2")! + let newerRelease = EqualizerDriverVersion(shortVersion: "0.2.0", build: "1")! + + XCTAssertLessThan(firstRelease, rebuilt) + XCTAssertLessThan(rebuilt, newerRelease) + XCTAssertEqual(firstRelease.description, "0.1 (1)") + XCTAssertEqual( + EqualizerDriverVersion(shortVersion: "0.1", build: "001"), + firstRelease + ) + } + + func testInvalidDriverVersionsAreRejected() { + XCTAssertNil(EqualizerDriverVersion(shortVersion: "", build: "1")) + XCTAssertNil(EqualizerDriverVersion(shortVersion: "0.1.x", build: "1")) + XCTAssertNil(EqualizerDriverVersion(shortVersion: "0.1.0", build: "")) + XCTAssertNil(EqualizerDriverVersion(shortVersion: "0.1.0", build: "1.2-beta")) + } + + func testInstallDecisionCoversMissingRepairAndStaleBundles() { + let missingDecision = EqualizerDriverInstallPolicy.decision( + bundled: firstRelease, + installed: .missing + ) + XCTAssertEqual(missingDecision, .install(reason: .missing)) + XCTAssertTrue(missingDecision.shouldInstall) + XCTAssertEqual( + EqualizerDriverInstallPolicy.decision( + bundled: firstRelease, + installed: .unreadable + ), + .install(reason: .repair) + ) + + let oldDriver = EqualizerDriverVersion(shortVersion: "0.0.9", build: "8")! + XCTAssertEqual( + EqualizerDriverInstallPolicy.decision( + bundled: firstRelease, + installed: .version(oldDriver) + ), + .install(reason: .stale) + ) + } + + func testInstallDecisionSkipsCurrentOrNewerBundle() { + XCTAssertEqual( + EqualizerDriverInstallPolicy.decision( + bundled: firstRelease, + installed: .version(firstRelease) + ), + .alreadyCurrent + ) + + let newerDriver = EqualizerDriverVersion(shortVersion: "0.1.0", build: "2")! + let newerDecision = EqualizerDriverInstallPolicy.decision( + bundled: firstRelease, + installed: .version(newerDriver) + ) + XCTAssertEqual(newerDecision, .alreadyCurrent) + XCTAssertFalse(newerDecision.shouldInstall) + } + + func testInstallErrorMappingKeepsDiagnosticsOutOfTheUserMessage() { + let error = EqualizerDriverInstallError.helperOperationFailed( + status: 74, + message: "copyfile failed" + ) + let mapped = EqualizerDriverInstallErrorMapper.map(error) + + XCTAssertEqual( + mapped.diagnosticDetails, + "Spotiglass EQ privileged helper failed (status 74): copyfile failed" + ) + XCTAssertFalse(mapped.isUserVisible) + XCTAssertNil(mapped.userFacingDescription) + } + + func testEveryHelperFailureHasDiagnosticMapping() { + let errors: [EqualizerDriverInstallError] = [ + .registrationFailed(status: 1), + .unregistrationFailed(status: 2), + .helperUnavailable(message: "not running"), + .helperRejected(status: 64, message: "invalid path"), + .helperOperationFailed(status: 74, message: "copy failed"), + .invalidReply, + .invalidRequest, + ] + + for error in errors { + let mapped = EqualizerDriverInstallErrorMapper.map(error) + XCTAssertEqual(mapped.diagnosticDetails, error.diagnosticDetails) + XCTAssertFalse(mapped.isUserVisible) + XCTAssertNil(mapped.userFacingDescription) + } + } + + func testCodeSigningRequirementPinsTeamOrExplicitLocalCertificate() { + let requirement = EqualizerPrivilegedHelperIdentity.requirement( + for: EqualizerPrivilegedHelperIdentity.applicationBundleIdentifier, + teamIdentifier: "TEAM123456", + localCertificateCommonName: "Spotiglass Local Dev" + ) + + XCTAssertEqual( + requirement, + "((anchor apple generic and certificate leaf[subject.OU] = \"TEAM123456\") or certificate leaf[subject.CN] = \"Spotiglass Local Dev\") and identifier \"com.isaaclins.spotiglass\"" + ) + } + + func testDriverLifecycleErrorsHaveNoUserFacingDescription() { + let errors: [EqualizerHALPluginError] = [ + .embeddedDriverMissing, + .embeddedDriverMetadataMissing, + .driverNotLoadedYet(installedPath: "/Library/Audio/Plug-Ins/HAL/SpotiglassEQDriver.driver"), + .driverInstallationFailed(diagnostic: "helper unavailable"), + ] + + for error in errors { + XCTAssertFalse(error.isUserVisible) + XCTAssertNil(error.userFacingDescription) + XCTAssertNotNil(error.diagnosticDetails) + } + } +} diff --git a/SpotiglassTests/EqualizerHALPluginTests.swift b/SpotiglassTests/EqualizerHALPluginTests.swift index 460cf9a..925eb8e 100644 --- a/SpotiglassTests/EqualizerHALPluginTests.swift +++ b/SpotiglassTests/EqualizerHALPluginTests.swift @@ -1,4 +1,5 @@ import CoreAudio +import Foundation import XCTest @testable import Spotiglass @@ -37,9 +38,32 @@ final class EqualizerHALPluginTests: XCTestCase { atomically: true, encoding: .utf8 ) + try? writeDriverVersion("0.1.0", build: "1", to: fixtureBundle) fakeAppBundle = Bundle(url: appDir) } + private func writeDriverVersion( + _ shortVersion: String, + build: String, + to driverURL: URL + ) throws { + let propertyList: [String: String] = [ + "CFBundleShortVersionString": shortVersion, + "CFBundleVersion": build, + ] + let data = try PropertyListSerialization.data( + fromPropertyList: propertyList, + format: .xml, + options: 0 + ) + let infoURL = driverURL.appendingPathComponent("Contents/Info.plist") + try FileManager.default.createDirectory( + at: infoURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try data.write(to: infoURL, options: .atomic) + } + override func tearDown() { EqualizerHALPluginController.clearForwardingTarget() try? FileManager.default.removeItem(at: halDirectory) @@ -84,11 +108,10 @@ final class EqualizerHALPluginTests: XCTestCase { XCTAssertNoThrow(try controller.uninstall()) } - func testEnableTrustsExistingInstalledDriverAndDoesNotReplaceIt() throws { - // The system HAL directory (/Library/Audio/Plug-Ins/HAL) is root-owned - // on macOS 26, so the unprivileged app process must not try to clobber - // an existing install. Once the .driver is present, enable() should - // leave it alone and proceed to route the default output. + func testInstallReplacesAStaleInstalledDriver() throws { + // The system HAL directory is root-owned on macOS 26, so production + // replacements happen in the helper. This isolated directory exercises + // the same version decision without touching the user's audio setup. let controller = EqualizerHALPluginController( halDirectory: halDirectory, fileManager: .default, @@ -98,23 +121,28 @@ final class EqualizerHALPluginTests: XCTestCase { let userFile = controller.installedDriverURL.appendingPathComponent("UserFile.txt") try "preserved".write(to: userFile, atomically: true, encoding: .utf8) - // Re-install. The existing bundle (and any sibling files in it) must - // survive — the controller has no business clobbering a root-owned - // install it can't actually rewrite in production. + // Mark the installed bundle stale, then let the policy trigger a + // replacement. The fresh payload should remove files from the old + // bundle rather than leave a mixed-version driver behind. + try writeDriverVersion( + "0.0.1", + build: "1", + to: controller.installedDriverURL + ) try controller.install() - XCTAssertTrue( + XCTAssertFalse( FileManager.default.fileExists(atPath: userFile.path), - "enable() must not replace an existing installed bundle" + "a stale installed bundle must be replaced as a unit" ) XCTAssertEqual( - try String(contentsOf: userFile, encoding: .utf8), - "preserved" + try String(contentsOf: controller.installedDriverURL.appendingPathComponent("Marker.txt"), encoding: .utf8), + "fake-driver-payload" ) } // MARK: - Error surfaces - func testEnableSurfacesEmbeddedDriverMissingWhenBundleHasNoPayload() { + func testInstallKeepsMissingEmbeddedDriverOutOfTheUserMessage() { let emptyAppBundle = Bundle(url: makeTempDirectory())! let controller = EqualizerHALPluginController( halDirectory: halDirectory, @@ -122,9 +150,13 @@ final class EqualizerHALPluginTests: XCTestCase { bundle: emptyAppBundle ) XCTAssertThrowsError(try controller.install()) { error in - guard case EqualizerHALPluginError.embeddedDriverMissing = error else { - return XCTFail("expected embeddedDriverMissing, got \(error)") + guard let driverError = error as? EqualizerHALPluginError else { + return XCTFail("expected EqualizerHALPluginError, got \(error)") + } + guard case .embeddedDriverMissing = driverError else { + return XCTFail("expected embeddedDriverMissing, got \(driverError)") } + XCTAssertNil(driverError.userFacingDescription) } } diff --git a/docs/building-and-testing.md b/docs/building-and-testing.md index d662302..0d8e55f 100644 --- a/docs/building-and-testing.md +++ b/docs/building-and-testing.md @@ -4,7 +4,7 @@ All commands assume the repository root as the current directory. ## Makefile -From the repo root, `make` / `make build` runs a Debug build into `build/DerivedData`. `make run` builds (if needed) and opens the Debug app. `make release` matches the unsigned Release layout below. `make test` runs unit tests with `-parallel-testing-enabled NO` so results stay deterministic (parallel runs can occasionally surface ordering races in unrelated suites). `make format` and `make lint` use [swift-format](https://github.com/swiftlang/swift-format) with the repo [`.swift-format`](../.swift-format) configuration (`brew install swift-format`). `make scan` runs `periphery scan` for dead-code detection and `tokei .` for line counts. Both run in one shell step so a failing Periphery build still prints Tokei output; the final exit status reflects Periphery first, then Tokei if Periphery succeeded. `make clean` removes `build/DerivedData` and deletes every **generic password** Keychain item whose **service** is exactly `com.isaaclins.spotiglass.spotify-auth` (the Spotify refresh token; see `KeychainRefreshTokenStore` in the app). That uses the `security` CLI against your default keychain list; you may be prompted for keychain access the same way the app would. For a full reset when testing auth, use `make clean && make build && make run`. Use `UNSIGNED=1` to pass `CODE_SIGNING_ALLOWED=NO` on Debug and test builds (same idea as the raw `xcodebuild` examples). +From the repo root, `make` / `make build` runs a Debug build into `build/DerivedData`. The Spotiglass scheme also builds and embeds the `SpotiglassEQPrivilegedHelper` LaunchDaemon and its plist. `make run` builds (if needed) and opens the Debug app. `make release` matches the unsigned Release layout below. `make test` runs unit tests with `-parallel-testing-enabled NO` so results stay deterministic (parallel runs can occasionally surface ordering races in unrelated suites). `make format` and `make lint` use [swift-format](https://github.com/swiftlang/swift-format) with the repo [`.swift-format`](../.swift-format) configuration (`brew install swift-format`). `make scan` runs `periphery scan` for dead-code detection and `tokei .` for line counts. Both run in one shell step so a failing Periphery build still prints Tokei output; the final exit status reflects Periphery first, then Tokei if Periphery succeeded. `make clean` removes `build/DerivedData` and deletes every **generic password** Keychain item whose **service** is exactly `com.isaaclins.spotiglass.spotify-auth` (the Spotify refresh token; see `KeychainRefreshTokenStore` in the app). That uses the `security` CLI against your default keychain list; you may be prompted for keychain access the same way the app would. For a full reset when testing auth, use `make clean && make build && make run`. Use `UNSIGNED=1` to pass `CODE_SIGNING_ALLOWED=NO` on Debug and test builds (same idea as the raw `xcodebuild` examples). ## App icon (Dock / Finder) and in-app logo @@ -95,7 +95,7 @@ This product uses only the ad-hoc linker signature macOS applies automatically. **Developer ID is the only certificate that works for distribution.** An `Apple Development` certificate produces a real `TeamIdentifier`, so a build signed with it looks signed, but Apple will not notarize it and Gatekeeper rejects it everywhere except a Mac that already trusts that development certificate. The script requires `Developer ID Application` and fails with an explanation if it is missing. Contributors who only need a local build can set `ALLOW_UNSIGNED_RELEASE=1`, which warns loudly and produces an ad-hoc build that must never be published. -**Nested code is signed explicitly, deepest first.** Signing a bundle seals its contents, so Sparkle's `Downloader.xpc`, `Installer.xpc`, `Autoupdate` and `Updater.app` are signed before `Sparkle.framework`, the audio driver is signed after it is embedded, and the app is signed last. `--deep` is deliberately not used: it is deprecated and applies one identity and entitlement set to nested code that may need different ones, which is a common cause of notarization rejections. +**Nested code is signed explicitly, deepest first.** Signing a bundle seals its contents, so Sparkle's `Downloader.xpc`, `Installer.xpc`, `Autoupdate` and `Updater.app` are signed before `Sparkle.framework`, the privileged helper and audio driver are signed after they are embedded, and the app is signed last. `--deep` is deliberately not used: it is deprecated and applies one identity and entitlement set to nested code that may need different ones, which is a common cause of notarization rejections. ### One time notarization setup diff --git a/docs/ci-and-releases.md b/docs/ci-and-releases.md index 4d6b670..8d69aa5 100644 --- a/docs/ci-and-releases.md +++ b/docs/ci-and-releases.md @@ -1,8 +1,9 @@ # CI and releases > **As of v0.2.0, real releases are cut locally** via `./scripts/sparkle-release.sh`, -> not from CI. The script embeds `SpotiglassEQDriver.driver`, signs every shipped -> bundle with the maintainer's Developer ID Application identity, submits the app +> not from CI. The script embeds `SpotiglassEQDriver.driver`, signs the nested +> privileged helper and every shipped bundle with the maintainer's Developer ID +> Application identity, submits the app > and disk image for notarization, and staples Apple's tickets. CI has neither the > signing identity nor the notary profile, and `coreaudiod` on macOS 26 rejects > ad-hoc-signed HAL plugins. A CI-published Sparkle update would therefore ship a @@ -10,7 +11,7 @@ ## Continuous integration -The workflow **CI** lives at [.github/workflows/ci.yml](../.github/workflows/ci.yml) and is what actually guards `main`. +The workflow **CI** lives at [.github/workflows/ci.yml](../.github/workflows/ci.yml) and is what actually guards `main`. The Spotiglass scheme builds the `SpotiglassEQPrivilegedHelper` target as part of the app build, while signed release packaging signs that nested helper before sealing the app. | Aspect | Detail | |--------|--------| @@ -92,8 +93,9 @@ The app checks the feed automatically about once per day, or via **Spotiglass ```sh ./scripts/sparkle-release.sh 0.5.0 7 docs/release-notes/v0.5.0.md ``` -5. The script builds and embeds the EQ driver, signs nested code from the inside - out, notarizes and staples the app, creates and notarizes the disk image, +5. The script builds and embeds the EQ driver, signs the privileged helper and + other nested code from the inside out, notarizes and staples the app, creates + and notarizes the disk image, signs the Sparkle archive with EdDSA, regenerates `docs/appcast.xml`, and bumps the project version last. Publish the generated zip and dmg in GitHub Release `v0.5.0`, then commit and push the appcast, release notes, and version bump. diff --git a/docs/equalizer-proof.md b/docs/equalizer-proof.md index 9c77547..89285fb 100644 --- a/docs/equalizer-proof.md +++ b/docs/equalizer-proof.md @@ -1,70 +1,60 @@ # Spotiglass EQ — proof bundle -What's automatically verified by `make test`, and what still requires a -manual walkthrough (`scripts/eq-qa.sh`) once a Developer ID-signed `.driver` -loads in `coreaudiod`. +What's automatically verified by XCTest, and what still requires a manual +walkthrough (`scripts/eq-qa.sh`) once a signed driver and privileged helper +load on a real Mac. ## Automatically verified (XCTest) -Run `make test`. Output is mirrored in the Xcode result bundle and the -test log. Relevant suites: +Run `make test`. Output is mirrored in the Xcode result bundle and the test +log. Relevant suites: | Suite | Cases | What it proves | -|---|---|---| +|---|---:|---| | `EqualizerCoefficientTests` | 7 | RBJ biquad math: zero-dB → bit-exact identity; peaking-EQ symmetry + 6 dB peak; low-shelf DC gain; high-shelf Nyquist gain; flat preset → identity coefficients; Bass Boost lifts only bands 0–3; every built-in preset emits finite, well-formed coefficients. | -| `EqualizerABRMSTests` | 3 | **Crit 5, A/B half** — Flat preset is bit-exact to bypass (zero RMS delta, far inside the ±0.05 dB bar). Bass Boost lifts 32 Hz RMS relative to 8 kHz RMS by >2 dB. No preset clips a −1 dBFS input above ±1.06. | +| `EqualizerABRMSTests` | 3 | **Crit 5, A/B half** — Flat preset is bit-exact to bypass, Bass Boost lifts 32 Hz relative to 8 kHz, and no preset clips a −1 dBFS input above ±1.06. | | `EQCoefficientPublisherTests` | 3 | Shared-memory IPC: writer round-trip, sequence always even at rest, monotonically increasing across writes. | -| `EqualizerHALPluginTests` | 5 | Install copies the embedded `.driver` into a temp HAL dir, uninstall is idempotent, re-install replaces stale files, missing-payload surfaces `embeddedDriverMissing` cleanly, all 7 built-ins produce distinguishable coefficient frames. | -| `EqualizerPresetsTests` | 7 | Resurrected from 2fdd179: built-in roster intact, JSON round-trip stable, normalization clamps, `find()` walks both built-ins and user presets, `apply()` writes preamp+bands+activePresetName atomically. | +| `EqualizerHALPluginTests` | 5+ | Isolated driver install/uninstall behavior, stale-bundle replacement, router readiness, output restoration, and all built-in coefficient frames without touching real CoreAudio output. | +| `EqualizerDriverInstallPolicyTests` | 7 | Driver release/build ordering, malformed-version handling, missing/stale/repair decisions, downgrade handling, and the rule that helper-install failures keep diagnostics out of user-facing error text. | +| `EqualizerPresetsTests` | 7 | Built-in roster intact, JSON round-trip stable, normalization clamps, `find()` walks both built-ins and user presets, and `apply()` writes preamp, bands, and activePresetName atomically. | Plus: | Script | What it proves | |---|---| -| `scripts/eq-mic-permission-audit.sh` | Zero hits for `NSMicrophoneUsage`, `AVCaptureDevice`, `inputNode`, `AudioHardwareCreateProcessTap`, `CATapDescription`, `kAudioObjectPropertyScopeInput` in `Spotiglass/` and `SpotiglassTests/`. Wired into `make test` as a prerequisite. | -| `SpotiglassEQDriver/build-driver.sh` | The C/C++ plugin source compiles cleanly on the macOS 26 SDK against `CoreAudio.framework` and produces a Mach-O universal bundle (x86_64 + arm64). | -| `make embed-driver` | The built `.driver` lands at `Spotiglass.app/Contents/Library/Audio/Plug-Ins/HAL/SpotiglassEQDriver.driver`, satisfying criterion 1's "the `.driver` is embedded in Spotiglass.app". | - -## Manually verified by user (criterion 5) - -These require a real audio environment + Developer ID signing. -`scripts/eq-qa.sh` walks the user through them and writes -`build/qa/manual-qa-.log`: - -1. **install** — toggling Enable in Settings → Equalizer copies the - embedded `.driver` to `~/Library/Audio/Plug-Ins/HAL/`. *(Path - automatically verified by `EqualizerHALPluginTests` against a fixture; - user confirms the real path is also written.)* -2. **coreaudiod kickstart** — `sudo launchctl kickstart -k system/com.apple.audio.coreaudiod` -3. **device-visible** — `system_profiler SPAudioDataType` shows Spotiglass EQ; - System Settings → Sound → Output lists it. **Requires Developer ID - signing.** -4. **default-route** — default output switches to Spotiglass EQ. *(Swift - path verified by `EqualizerHALPluginController` unit tests.)* -5. **preset:Flat … preset:Loudness** — tonality shifts as expected. *(The - DSP math is verified by `EqualizerABRMSTests`; the listening half is - the user's call.)* -6. **save-preset** — "MyTest" lands in `~/.config/spotiglass/settings.json`. - *(Persistence path verified by `EqualizerPresetsTests`.)* -7. **reload-preset** — survives a quit + relaunch. -8. **delete-preset** — preset disappears. -9. **disable-route** — default output restored. -10. **uninstall** — driver gone from `~/Library/Audio/Plug-Ins/HAL/`. -11. **default-restored** — original default device is back. +| `scripts/eq-mic-permission-audit.sh` | Zero hits for microphone, process-tap, and input-scope APIs in the EQ code. | +| `SpotiglassEQDriver/build-driver.sh` | The C/C++ plugin compiles cleanly against the macOS SDK and produces a Mach-O universal bundle. | +| `make embed-driver` | The built `.driver` lands at `Spotiglass.app/Contents/Library/Audio/Plug-Ins/HAL/SpotiglassEQDriver.driver`. | + +## Manually verified by user + +These require a real audio environment and a signed, notarized build: + +1. **Authorization + install** — toggling Enable Equalizer produces macOS's + standard authorization prompt for the registered LaunchDaemon, then writes + the driver to `/Library/Audio/Plug-Ins/HAL/`. +2. **CoreAudio restart** — the helper restarts `coreaudiod` and the virtual + device is re-enumerated without a shell command or log-out. +3. **Device-visible** — System Settings → Sound → Output lists Spotiglass EQ. +4. **Default-route** — the default output switches to Spotiglass EQ. +5. **Preset: Flat … Loudness** — the DSP math is covered by XCTest; listening + confirms the expected tonal changes. +6. **Save, reload, and delete preset** — the saved curve survives relaunch and + can be removed from the picker and settings file. +7. **Disable-route** — the previous default output is restored. +8. **Upgrade + repair** — a stale or damaged installed bundle is replaced by + the helper without another authorization prompt and without a user-facing + error or repair action. + +The helper registration and authorization prompt cannot be proven by the +unsigned CI build. They need a signed build running on a real Mac. ## Honest gap inventory -What's NOT done in this codebase: - -- **Developer ID signing** of the embedded `.driver`. Without it, macOS 26 - `coreaudiod` will refuse to register the device. The driver itself is - ad-hoc signed during `build-driver.sh`; the user must re-sign with a - Developer ID identity before installing on a production machine. -- **Property dispatcher** in `SpotiglassEQPlugin.cpp` — `HasProperty`, - `GetPropertyData`, `GetPropertyDataSize`, `IsPropertySettable`, - `SetPropertyData`. These are the AudioObject ABI handlers that - coreaudiod calls before it'll register the device. Marked `TODO(PROP)`. - ~600-1000 lines of selector-switch boilerplate; Apple's `NullAudio` - sample is the canonical template. -- **Xcode target** for the `.driver`. Currently built via `clang` from - `make embed-driver`. Documented under `docs/equalizer-xcode-target.md`. +- **Developer ID signing:** CI builds are intentionally unsigned. The release + script signs the app, helper, and driver before notarization. Verify that + the signed artifact contains the helper under + `Contents/Library/PrivilegedHelperTools` and the plist under + `Contents/Library/LaunchDaemons`. +- **CoreAudio behavior:** a valid signature does not prove that `coreaudiod` + accepts the driver. Verify device enumeration and audible forwarding by hand. diff --git a/docs/equalizer-qa.md b/docs/equalizer-qa.md index b068a0c..c21b934 100644 --- a/docs/equalizer-qa.md +++ b/docs/equalizer-qa.md @@ -5,18 +5,20 @@ Run `scripts/eq-qa.sh` to walk the success-criterion-5 checklist. Each PASS/FAIL/SKIP is appended to `manual-qa-.log` along with a UTC timestamp. -Steps: +The install checks require a signed build on a real Mac. The first enable must +show macOS's standard authorization prompt for the Spotiglass LaunchDaemon. +The app should not show Terminal instructions or a repair button. | # | Step | What you confirm | |---|---|---| -| 1 | install | Enable toggle copies `SpotiglassEQDriver.driver` into `~/Library/Audio/Plug-Ins/HAL/`. | -| 2 | coreaudiod kickstart | `sudo launchctl kickstart -k system/com.apple.audio.coreaudiod` (or log-out/in) makes the driver visible. | +| 1 | authorization + install | Enable Equalizer. macOS presents its standard authorization dialog once, then the helper copies `SpotiglassEQDriver.driver` into `/Library/Audio/Plug-Ins/HAL/`. | +| 2 | coreaudiod restart | The helper restarts `coreaudiod`; no command or log-out is required. | | 3 | device-visible | `system_profiler SPAudioDataType` shows "Spotiglass EQ"; System Settings → Sound → Output lists it. | -| 4 | default-route | Default output device switches to Spotiglass EQ on enable. | +| 4 | default-route | The default output device switches to Spotiglass EQ on enable. | | 5 | preset:Flat … preset:Loudness | Each of the 7 built-ins applies; listen for the expected tonality shift. | | 6 | save-preset | "Save preset…" with name "MyTest" lands in the Saved section + `~/.config/spotiglass/settings.json`. | | 7 | reload-preset | After relaunch, "MyTest" is still in the picker. | | 8 | delete-preset | Delete removes "MyTest" from both UI and settings.json. | | 9 | disable-route | Disable restores the previous default output device. | -| 10 | uninstall | Removing the `.driver` makes the device disappear after the next coreaudiod refresh. | +| 10 | silent upgrade + repair | Install an older driver, then enable with a newer app or a damaged bundle. The helper replaces it without another authorization prompt or user-facing error. | | 11 | default-restored | Original default output is back. | diff --git a/docs/equalizer-xcode-target.md b/docs/equalizer-xcode-target.md index b0eec1c..acfaa42 100644 --- a/docs/equalizer-xcode-target.md +++ b/docs/equalizer-xcode-target.md @@ -1,10 +1,12 @@ # Adding `SpotiglassEQDriver.driver` as an Xcode target -The current build path is `make embed-driver`, which uses `clang` via -`SpotiglassEQDriver/build-driver.sh` and a Makefile copy step. That keeps -the EQ driver buildable without touching `Spotiglass.xcodeproj`. The -recommended long-term path is a real Xcode target. Until that lands, this -document is what someone wiring it up should follow. +The EQ driver still has a standalone `clang` build path through +`make embed-driver`, while the privileged installer is a real Xcode target in +`Spotiglass.xcodeproj`. The app embeds that target under +`Contents/Library/PrivilegedHelperTools` and its LaunchDaemon plist under +`Contents/Library/LaunchDaemons`; `SMAppService` registers the plist when EQ is +enabled. This document covers the separate driver target that may be wired in +later. ## Steps in Xcode UI @@ -67,11 +69,36 @@ Developer ID signed. Options: 1. Disable amfi restrictions on a development machine (not recommended) 2. Get a Developer ID identity and sign locally +## Privileged installer target + +`SpotiglassEQPrivilegedHelper` is a macOS tool target. The application target +must keep both of these build phases and the target dependency: + +- copy the helper executable to `Contents/Library/PrivilegedHelperTools` with + **Code Sign On Copy** enabled; +- copy `com.isaaclins.spotiglass.eqprivilegedhelper.plist` to + `Contents/Library/LaunchDaemons`; +- build the helper before the application so the paths named by + `BundleProgram` and `SMAppService.daemon(plistName:)` exist in the signed + bundle. + +The helper accepts only requests from the Spotiglass code-signing identifier, +only accepts the driver's path inside the containing app bundle, and preserves +file metadata while replacing the system-scope bundle. It restarts `coreaudiod` +after a copy. A Developer ID-signed and notarized app is required for a +LaunchDaemon to be accepted on a user's Mac. + ## Verification after wiring the target ```bash make build -# Check the .driver is embedded: +# Check the privileged helper is embedded: +ls "build/DerivedData/Build/Products/Debug/Spotiglass.app/Contents/Library/PrivilegedHelperTools/" +# → SpotiglassEQPrivilegedHelper +ls "build/DerivedData/Build/Products/Debug/Spotiglass.app/Contents/Library/LaunchDaemons/" +# → com.isaaclins.spotiglass.eqprivilegedhelper.plist + +# `make embed-driver` additionally embeds the .driver: ls "build/DerivedData/Build/Products/Debug/Spotiglass.app/Contents/Library/Audio/Plug-Ins/HAL/" # → SpotiglassEQDriver.driver @@ -80,11 +107,8 @@ file "build/DerivedData/Build/Products/Debug/Spotiglass.app/Contents/Library/Aud # → Mach-O universal binary with 2 architectures: [x86_64...] [arm64...] ``` -Once Developer ID signing is in place, install + reload coreaudiod: - -```bash -cp -R "build/DerivedData/.../Spotiglass.app/Contents/Library/Audio/Plug-Ins/HAL/SpotiglassEQDriver.driver" \ - ~/Library/Audio/Plug-Ins/HAL/ -sudo launchctl kickstart -k system/com.apple.audio.coreaudiod -system_profiler SPAudioDataType | grep -A2 "Spotiglass EQ" -``` +Once Developer ID signing is in place, launch Spotiglass and enable the +Equalizer. The app registers the LaunchDaemon, macOS asks for authorization, +and the helper installs the driver and restarts `coreaudiod`. Confirm the +result in System Settings → Sound → Output. `scripts/eq-qa.sh` records the +manual checks. diff --git a/docs/equalizer.md b/docs/equalizer.md index 1f305bb..fd47430 100644 --- a/docs/equalizer.md +++ b/docs/equalizer.md @@ -10,12 +10,14 @@ using vDSP biquads at the device's native sample rate, with no resampling. ## How users turn it on 1. Open **Settings → Equalizer** in Spotiglass. -2. Toggle **Enable Equalizer**. On the first enable Spotiglass copies the - bundled `SpotiglassEQDriver.driver` into `~/Library/Audio/Plug-Ins/HAL/`, - re-loads `coreaudiod` (see "Activating the driver" below), and routes the - system default output to `"Spotiglass EQ"`. Spotify Web Playback SDK audio - is already wired to the system default, so it picks up the new device - without restarting playback. +2. Toggle **Enable Equalizer**. On the first enable Spotiglass registers its + privileged helper with `SMAppService`. macOS shows its standard + authorization prompt once, then the helper copies the bundled + `SpotiglassEQDriver.driver` into `/Library/Audio/Plug-Ins/HAL/`, restarts + `coreaudiod`, and routes the system default output to `"Spotiglass EQ"`. + Spotify Web Playback SDK audio is already wired to the system default, so it + picks up the new device without restarting playback. Later installs, + upgrades, and repairs happen without another prompt. 3. Pick a built-in preset (Flat, Bass Boost, Vocal, Treble Boost, Acoustic, Electronic, Loudness) or drag the 10 sliders to taste. The 80 Hz, 170 Hz, 310 Hz, 600 Hz, 1 kHz, 3 kHz, 6 kHz, 12 kHz, 14 kHz, 16 kHz bands and the @@ -23,8 +25,10 @@ using vDSP biquads at the device's native sample rate, with no resampling. 4. Save custom curves as user presets. Everything persists to `~/.config/spotiglass/settings.json`. -Disabling restores the previous default output and removes the -`.driver` from the user's HAL directory. +Disabling restores the previous default output. The driver remains installed +so the next enable is silent; a later repair or upgrade only invokes the +already-approved helper when the bundled version changes or CoreAudio needs a +reload. ## Architecture @@ -64,7 +68,7 @@ output device that runs DSP in the kernel-adjacent path: | | AudioServerPlugIn | AudioDriverKit | |---|---|---| | Runs in | `coreaudiod` (user-space, root daemon) | DriverKit dext (sandboxed user space) | -| Install path | `~/Library/Audio/Plug-Ins/HAL/.driver` | App bundle (`Contents/Library/SystemExtensions`) + `systemextensionsctl` | +| Install path | `/Library/Audio/Plug-Ins/HAL/.driver` via the registered helper | App bundle (`Contents/Library/SystemExtensions`) + `systemextensionsctl` | | Entitlement | None (signed app is sufficient) | **`com.apple.developer.driverkit.transport.coreaudio`** — Apple-gated, request-only | | User interaction to install | Plugin copy + coreaudiod restart | OSSystemExtensionRequest + user approval in System Settings → Privacy & Security | | Long-term support | Maintained, used by BlackHole / BackgroundMusic / Loopback | Apple's recommended forward path, but practically blocked for indies | @@ -81,8 +85,8 @@ Apple only on request and is not available to indie macOS developers without a formal review. Without it the DriverKit dext refuses to load. Every shipping third-party EQ on macOS (eqMac, BackgroundMusic, BlackHole, Loopback, Soundsource) uses `AudioServerPlugIn` for exactly this reason. Spotiglass -adopts the same path: a bundled `.driver`, copied to the user's HAL directory -on first enable, no Apple entitlement gate. +adopts the same path: a bundled `.driver`, copied to the system HAL directory +by the registered helper on first enable, with no Apple entitlement gate. ### Consequences @@ -91,13 +95,8 @@ on first enable, no Apple entitlement gate. - **Pro:** Standard install path means dot-files / Migration Assistant pick it up correctly. - **Con:** Activating the driver requires `coreaudiod` to re-load the HAL - directory, which `launchctl kickstart -k system/com.apple.audio.coreaudiod` - forces but needs `sudo`. Spotiglass handles this by writing the plugin to - `~/Library/Audio/Plug-Ins/HAL/` without sudo and then surfacing a - one-line prompt to the user: *"Run `sudo launchctl kickstart -k system/com.apple.audio.coreaudiod` - or log out and back in to activate the Spotiglass EQ driver."* This is the - same activation step every other CoreAudio plugin asks for on first - install. + directory. The registered privileged helper performs that restart after it + copies the bundle, so the app never asks the user to run a shell command. - **Con:** Code signing requirements apply. Production builds need a real Developer ID signature for the `.driver` bundle; ad-hoc signing usually loads only when SIP / amfi loosening is in place. Documented under "Known @@ -232,15 +231,20 @@ The control is published via the device's `kAudioObjectPropertyControlList` ## Activating the driver -On first enable Spotiglass copies the embedded `SpotiglassEQDriver.driver` to -`/Library/Audio/Plug-Ins/HAL/`. On macOS 26 `coreaudiod` only scans the -system-scope HAL directory (the legacy `~/Library/Audio/Plug-Ins/HAL/` is -ignored), so the install requires `sudo`. Since the GUI process never runs -sudo on the user's behalf, the controller stages a copy in -`~/Library/Application Support/Spotiglass/staged-driver/` and surfaces the -exact `sudo cp -pR …` command for the user to run in Terminal. After the -copy, the user reloads coreaudiod with `sudo killall coreaudiod` (the -`launchctl kickstart` route is blocked by SIP on macOS 26). +On first enable Spotiglass registers +`com.isaaclins.spotiglass.eqprivilegedhelper.plist` with +`SMAppService.daemon(plistName:)`. macOS presents its own administrator +authorization dialog for the LaunchDaemon. The helper then copies the +embedded `SpotiglassEQDriver.driver` to `/Library/Audio/Plug-Ins/HAL/` while +preserving the bundle metadata required by CoreAudio, and restarts +`coreaudiod`. The app waits for the virtual output to reappear before routing +the system default to it. + +The app compares the installed driver's `CFBundleShortVersionString` and +`CFBundleVersion` with the bundled copy. A missing or older bundle is installed +again; an unreadable bundle is repaired. An exact or newer installed version is +left alone unless CoreAudio needs a reload. These operations use the already +approved helper and remain silent after the first authorization. After activation, `"Spotiglass EQ"` appears in **System Settings → Sound → Output**. Spotiglass flips the system default output to it via @@ -293,7 +297,9 @@ Two paths: 2. **`./SpotiglassEQDriver/build-driver.sh`** alone — builds the driver bundle at `build/SpotiglassEQDriver.driver` without touching the host - app. Useful for inspecting the Mach-O or for CI. + app. Useful for inspecting the Mach-O or for CI. Release packaging passes + `DRIVER_MARKETING_VERSION` and `DRIVER_BUILD_VERSION` so the helper can + detect driver upgrades. A fully-wired Xcode target (proper integration with Run/Test schemes, no Makefile escape hatch) is documented in `docs/equalizer-xcode-target.md` @@ -319,9 +325,9 @@ as the recommended next step once Developer ID signing is wired up. `CODE SIGNING: rejecting invalid page`. `cp -pR` preserves mtimes; or re-sign in place at the destination (`sudo codesign --force --sign /Library/Audio/Plug-Ins/HAL/SpotiglassEQDriver.driver`). -- **coreaudiod restart:** macOS does not provide a sudo-free way to reload - HAL plugins from `~/Library/Audio/Plug-Ins/HAL/`. Spotiglass surfaces - instructions but does not run sudo on the user's behalf. +- **coreaudiod restart:** The helper restarts `coreaudiod` after each driver + install or repair. A signed build on a real Mac is still needed to verify + that the authorization and daemon lifecycle behave as expected. - **Sample-rate changes:** When the device's active sample rate changes (e.g., user picks a new monitor), the driver re-publishes the rate and the GUI process re-derives coefficients. Brief glitch may be audible during diff --git a/scripts/coverage-allowlist.json b/scripts/coverage-allowlist.json index 40de1a4..1c77af2 100644 --- a/scripts/coverage-allowlist.json +++ b/scripts/coverage-allowlist.json @@ -14,6 +14,7 @@ "Spotiglass/Pinning/PinnedItemTransfer.swift", "Spotiglass/Playback/AudioEqualizerEngine.swift", "Spotiglass/Playback/EqualizerHALPluginController.swift", + "Spotiglass/Playback/EqualizerPrivilegedHelperClient.swift", "Spotiglass/Playback/PlaybackControlsView.swift", "Spotiglass/Playback/SpotifyPlaybackBridge.swift", "Spotiglass/Services/SpotifyAPIClient+Home.swift", diff --git a/scripts/eq-qa.sh b/scripts/eq-qa.sh index f4d61f2..43900b7 100755 --- a/scripts/eq-qa.sh +++ b/scripts/eq-qa.sh @@ -4,9 +4,8 @@ # Usage: scripts/eq-qa.sh # # Walks the tester through the success-criterion-5 checklist: -# install → enable → device-visible → route → toggle each preset → -# save+reload "MyTest" → delete → disable → uninstall → -# directory-gone + default-restored +# authorization + install → enable → device-visible → route → toggle each preset → +# save+reload "MyTest" → delete → disable → default-restored # # At each step the script prints a "do X, then press y or n" prompt and # appends the result + timestamp + a screenshot-prompt to build/qa/manual-qa.log. @@ -15,7 +14,7 @@ set -eu LOG_DIR="build/qa" LOG_FILE="$LOG_DIR/manual-qa-$(date +%Y%m%d-%H%M%S).log" -HAL_DIR="$HOME/Library/Audio/Plug-Ins/HAL" +HAL_DIR="/Library/Audio/Plug-Ins/HAL" DRIVER_NAME="SpotiglassEQDriver.driver" mkdir -p "$LOG_DIR" @@ -49,17 +48,20 @@ log "BEGIN Spotiglass EQ manual QA" log "HAL dir: $HAL_DIR" log "Looking for: $DRIVER_NAME" -# 1. install -prompt "install" \ - "Launch Spotiglass. Open Settings → Equalizer. Toggle 'Enable Equalizer'. \ -The app should copy SpotiglassEQDriver.driver into ~/Library/Audio/Plug-Ins/HAL/. \ -Check: \`ls $HAL_DIR\` shows the driver." \ - "Settings → Equalizer pane with toggle ON" +# 1. authorization + install +prompt "authorization + install" \ + "Launch Spotiglass. Open Settings → Equalizer and toggle 'Enable Equalizer'. \ +macOS should show its standard authorization prompt once. After approval, the \ +helper should copy SpotiglassEQDriver.driver into /Library/Audio/Plug-Ins/HAL/. \ +Check: \`ls /Library/Audio/Plug-Ins/HAL\` shows the driver, and the pane never \ +shows Terminal instructions or a repair button." \ + "Settings → Equalizer pane with toggle ON and the macOS authorization dialog" # 2. coreaudiod activation -prompt "coreaudiod kickstart" \ - "Run: sudo launchctl kickstart -k system/com.apple.audio.coreaudiod \ -(or log out and back in). This is a one-time CoreAudio activation." \ +prompt "coreaudiod restart" \ + "The helper should restart coreaudiod after the copy. Do not run a command or \ +log out and back in. The virtual device should appear after the app waits for \ +CoreAudio to re-enumerate it." \ "" # 3. enable + device-visible @@ -111,14 +113,15 @@ back to whatever it was before you enabled the EQ. Run \ \`system_profiler SPAudioDataType | grep -i default\` to confirm." \ "" -# 10. uninstall -prompt "uninstall" \ - "Click 'Uninstall driver' in the Equalizer pane (if surfaced) OR remove \ -$HAL_DIR/$DRIVER_NAME manually. Confirm \`ls $HAL_DIR\` no longer shows the \ -driver. Re-run the coreaudiod kickstart so the device disappears." \ +# 10. silent upgrade + repair +prompt "silent upgrade + repair" \ + "Use a signed build with an older installed driver, then enable Equalizer \ +with a newer bundled driver. Also repeat with a damaged installed bundle. The \ +helper should replace it without another authorization prompt, Terminal \ +instructions, or a repair button." \ "" -# 11. default restored after uninstall +# 11. default restored after disable prompt "default-restored" \ "In System Settings → Sound → Output, confirm the default output device \ is back to the original (whatever you used before step 1)." \ diff --git a/scripts/setup-eq-driver-signing.sh b/scripts/setup-eq-driver-signing.sh index 9aad46e..817f402 100755 --- a/scripts/setup-eq-driver-signing.sh +++ b/scripts/setup-eq-driver-signing.sh @@ -52,8 +52,6 @@ else fi echo -echo "OK. Now you can build + sign the driver:" -echo " make build-driver" -echo " codesign --force --sign \"Apple Development: \$YOUR_EMAIL\" build/SpotiglassEQDriver.driver" -echo " sudo cp -pR build/SpotiglassEQDriver.driver /Library/Audio/Plug-Ins/HAL/" -echo " sudo killall coreaudiod" +echo "OK. The Apple Development identity is ready for signed builds." +echo "Launch a signed Spotiglass build and enable Equalizer; macOS will" +echo "authorize the helper, which installs the driver and restarts coreaudiod." diff --git a/scripts/sparkle-release.sh b/scripts/sparkle-release.sh index 47bb83d..043b2e4 100755 --- a/scripts/sparkle-release.sh +++ b/scripts/sparkle-release.sh @@ -224,7 +224,12 @@ xcodebuild \ clean build echo "==> Building SpotiglassEQDriver" -(cd "$ROOT/SpotiglassEQDriver" && bash build-driver.sh) +( + cd "$ROOT/SpotiglassEQDriver" + DRIVER_MARKETING_VERSION="$MARKETING_VERSION" \ + DRIVER_BUILD_VERSION="$BUILD_NUMBER" \ + bash build-driver.sh +) # Embed the driver before signing anything. Signing a bundle seals its contents, # so every nested item has to be in place first, otherwise adding the driver @@ -290,6 +295,11 @@ if [[ -n "$DEVELOPER_ID_IDENTITY" ]]; then sign_bundle "$SPARKLE_VERSIONS/Updater.app" sign_bundle "$SPARKLE_FRAMEWORK" + # The privileged helper is launched by system launchd rather than by the + # app, so it needs its own hardened-runtime signature before the app seals + # the nested executable. + sign_bundle "$APP_SOURCE/Contents/Library/PrivilegedHelperTools/SpotiglassEQPrivilegedHelper" + # The audio driver is loaded by coreaudiod rather than by the app, so it is # signed as its own bundle. The hardened runtime is required for notarization; # verify audio still works on a signed build before publishing. From 4317888af4da178f7ddaa7c210e182a2a3cd717e Mon Sep 17 00:00:00 2001 From: isaaclins <104733575+isaaclins@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:03:46 +0200 Subject: [PATCH 2/4] fix(equalizer): pin privileged helper signatures --- Spotiglass/App/SparkleInfo.plist | 2 - .../EqualizerPrivilegedHelperClient.swift | 2 +- .../EqualizerPrivilegedHelperIdentity.swift | 49 +++++-------------- SpotiglassEQPrivilegedHelper/Info.plist | 2 - .../EqualizerDriverInstallPolicyTests.swift | 18 ++++--- 5 files changed, 22 insertions(+), 51 deletions(-) diff --git a/Spotiglass/App/SparkleInfo.plist b/Spotiglass/App/SparkleInfo.plist index 8f71f80..58e73ff 100644 --- a/Spotiglass/App/SparkleInfo.plist +++ b/Spotiglass/App/SparkleInfo.plist @@ -4,8 +4,6 @@ SUFeedURL https://isaaclins.com/spotiglass/appcast.xml - SpotiglassCodeSigningTeamIdentifier - $(DEVELOPMENT_TEAM) SUPublicEDKey HknEj0Snyq5WsrWwAxj89njv+qkdMASLlzKMFrlog8Y= SUEnableAutomaticChecks diff --git a/Spotiglass/Playback/EqualizerPrivilegedHelperClient.swift b/Spotiglass/Playback/EqualizerPrivilegedHelperClient.swift index 3fe3fca..456e3db 100644 --- a/Spotiglass/Playback/EqualizerPrivilegedHelperClient.swift +++ b/Spotiglass/Playback/EqualizerPrivilegedHelperClient.swift @@ -153,7 +153,7 @@ final class EqualizerPrivilegedHelperClient: @unchecked Sendable, EqualizerDrive with: EqualizerPrivilegedHelperProtocol.self ) connection.setCodeSigningRequirement( - EqualizerPrivilegedHelperIdentity.helperRequirement(for: bundle) + EqualizerPrivilegedHelperIdentity.helperRequirement ) let completion = DispatchSemaphore(value: 0) diff --git a/Spotiglass/Playback/EqualizerPrivilegedHelperIdentity.swift b/Spotiglass/Playback/EqualizerPrivilegedHelperIdentity.swift index 6e61299..805be52 100644 --- a/Spotiglass/Playback/EqualizerPrivilegedHelperIdentity.swift +++ b/Spotiglass/Playback/EqualizerPrivilegedHelperIdentity.swift @@ -13,48 +13,21 @@ enum EqualizerPrivilegedHelperIdentity { static let driverRelativePath = "Contents/Library/Audio/Plug-Ins/HAL" static let resourceDriverRelativePath = "Contents/Resources" static let systemHALDirectory = URL(fileURLWithPath: "/Library/Audio/Plug-Ins/HAL", isDirectory: true) + static let signingTeamIdentifier = "BHAF4L4726" static let localCertificateCommonName = "Spotiglass Local Dev" - static let teamIdentifierInfoKey = "SpotiglassCodeSigningTeamIdentifier" - static var helperRequirement: String { - requirement(for: helperBundleIdentifier) - } - - static var clientRequirement: String { - requirement(for: applicationBundleIdentifier) - } - - static var driverRequirement: String { - requirement(for: driverBundleIdentifier) - } + // Apple-signed builds are pinned to the distribution team's leaf OU and + // Apple chain. The local self-signed identity is an explicit development + // alternative because it has no Apple team identifier. + private static let trustedSignerRequirement = + "(anchor apple generic and certificate leaf[subject.OU] = \"\(signingTeamIdentifier)\") or certificate leaf[subject.CN] = \"\(localCertificateCommonName)\"" - static func helperRequirement(for bundle: Bundle) -> String { - requirement(for: helperBundleIdentifier, bundle: bundle) - } - - /// Builds the pure signer requirement used by all three code objects. - /// Developer ID and Apple Development certificates identify the team via - /// their leaf OU; the local development certificate has no team OU, so its - /// stable common name is the explicit development-only alternative. - static func requirement( - for identifier: String, - teamIdentifier: String, - localCertificateCommonName: String = Self.localCertificateCommonName - ) -> String { - "((anchor apple generic and certificate leaf[subject.OU] = \"\(teamIdentifier)\") or certificate leaf[subject.CN] = \"\(localCertificateCommonName)\") and identifier \"\(identifier)\"" - } - - private static func requirement(for identifier: String, bundle: Bundle = .main) -> String { - requirement( - for: identifier, - teamIdentifier: teamIdentifier(from: bundle) - ) - } + static let helperRequirement = requirement(for: helperBundleIdentifier) + static let clientRequirement = requirement(for: applicationBundleIdentifier) + static let driverRequirement = requirement(for: driverBundleIdentifier) - private static func teamIdentifier(from bundle: Bundle) -> String { - let configured = bundle.object(forInfoDictionaryKey: teamIdentifierInfoKey) as? String - let trimmed = configured?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? "UNCONFIGURED" : trimmed + static func requirement(for identifier: String) -> String { + "(\(trustedSignerRequirement)) and identifier \"\(identifier)\"" } static var helperVersionURL: URL { diff --git a/SpotiglassEQPrivilegedHelper/Info.plist b/SpotiglassEQPrivilegedHelper/Info.plist index 60d1d4e..c367be4 100644 --- a/SpotiglassEQPrivilegedHelper/Info.plist +++ b/SpotiglassEQPrivilegedHelper/Info.plist @@ -14,7 +14,5 @@ SpotiglassEQPrivilegedHelper CFBundleVersion $(CURRENT_PROJECT_VERSION) - SpotiglassCodeSigningTeamIdentifier - $(DEVELOPMENT_TEAM) diff --git a/SpotiglassTests/EqualizerDriverInstallPolicyTests.swift b/SpotiglassTests/EqualizerDriverInstallPolicyTests.swift index 02e60f5..8999568 100644 --- a/SpotiglassTests/EqualizerDriverInstallPolicyTests.swift +++ b/SpotiglassTests/EqualizerDriverInstallPolicyTests.swift @@ -102,16 +102,18 @@ final class EqualizerDriverInstallPolicyTests: XCTestCase { } } - func testCodeSigningRequirementPinsTeamOrExplicitLocalCertificate() { - let requirement = EqualizerPrivilegedHelperIdentity.requirement( - for: EqualizerPrivilegedHelperIdentity.applicationBundleIdentifier, - teamIdentifier: "TEAM123456", - localCertificateCommonName: "Spotiglass Local Dev" + func testCodeSigningRequirementsPinTeamOrExplicitLocalCertificate() { + XCTAssertEqual( + EqualizerPrivilegedHelperIdentity.clientRequirement, + "((anchor apple generic and certificate leaf[subject.OU] = \"BHAF4L4726\") or certificate leaf[subject.CN] = \"Spotiglass Local Dev\") and identifier \"com.isaaclins.spotiglass\"" + ) + XCTAssertEqual( + EqualizerPrivilegedHelperIdentity.helperRequirement, + "((anchor apple generic and certificate leaf[subject.OU] = \"BHAF4L4726\") or certificate leaf[subject.CN] = \"Spotiglass Local Dev\") and identifier \"com.isaaclins.spotiglass.eqprivilegedhelper\"" ) - XCTAssertEqual( - requirement, - "((anchor apple generic and certificate leaf[subject.OU] = \"TEAM123456\") or certificate leaf[subject.CN] = \"Spotiglass Local Dev\") and identifier \"com.isaaclins.spotiglass\"" + EqualizerPrivilegedHelperIdentity.driverRequirement, + "((anchor apple generic and certificate leaf[subject.OU] = \"BHAF4L4726\") or certificate leaf[subject.CN] = \"Spotiglass Local Dev\") and identifier \"com.isaaclins.spotiglass.eqdriver\"" ) } From 9a57ccad3a9448ec77bf350f71e8b405dfb8457a Mon Sep 17 00:00:00 2001 From: isaaclins <104733575+isaaclins@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:12:00 +0200 Subject: [PATCH 3/4] fix: keep shared signer policy in dead-code scan --- .../Playback/EqualizerPrivilegedHelperIdentity.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Spotiglass/Playback/EqualizerPrivilegedHelperIdentity.swift b/Spotiglass/Playback/EqualizerPrivilegedHelperIdentity.swift index 805be52..7021763 100644 --- a/Spotiglass/Playback/EqualizerPrivilegedHelperIdentity.swift +++ b/Spotiglass/Playback/EqualizerPrivilegedHelperIdentity.swift @@ -6,9 +6,7 @@ import Foundation enum EqualizerPrivilegedHelperIdentity { static let machServiceName = "com.isaaclins.spotiglass.eqprivilegedhelper" static let plistName = "com.isaaclins.spotiglass.eqprivilegedhelper.plist" - static let applicationBundleIdentifier = "com.isaaclins.spotiglass" static let helperBundleIdentifier = "com.isaaclins.spotiglass.eqprivilegedhelper" - static let driverBundleIdentifier = "com.isaaclins.spotiglass.eqdriver" static let driverBundleName = "SpotiglassEQDriver.driver" static let driverRelativePath = "Contents/Library/Audio/Plug-Ins/HAL" static let resourceDriverRelativePath = "Contents/Resources" @@ -23,8 +21,12 @@ enum EqualizerPrivilegedHelperIdentity { "(anchor apple generic and certificate leaf[subject.OU] = \"\(signingTeamIdentifier)\") or certificate leaf[subject.CN] = \"\(localCertificateCommonName)\"" static let helperRequirement = requirement(for: helperBundleIdentifier) - static let clientRequirement = requirement(for: applicationBundleIdentifier) - static let driverRequirement = requirement(for: driverBundleIdentifier) + + // These are consumed by the helper target, which Periphery analyzes separately. + // periphery:ignore + static let clientRequirement = requirement(for: "com.isaaclins.spotiglass") + // periphery:ignore + static let driverRequirement = requirement(for: "com.isaaclins.spotiglass.eqdriver") static func requirement(for identifier: String) -> String { "(\(trustedSignerRequirement)) and identifier \"\(identifier)\"" From 21b5420304d59950bca862f611b7777d528da425 Mon Sep 17 00:00:00 2001 From: isaaclins <104733575+isaaclins@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:19:46 +0200 Subject: [PATCH 4/4] test: allowlist shared helper identity coverage --- scripts/coverage-allowlist.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/coverage-allowlist.json b/scripts/coverage-allowlist.json index 1c77af2..c36747b 100644 --- a/scripts/coverage-allowlist.json +++ b/scripts/coverage-allowlist.json @@ -15,6 +15,7 @@ "Spotiglass/Playback/AudioEqualizerEngine.swift", "Spotiglass/Playback/EqualizerHALPluginController.swift", "Spotiglass/Playback/EqualizerPrivilegedHelperClient.swift", + "Spotiglass/Playback/EqualizerPrivilegedHelperIdentity.swift", "Spotiglass/Playback/PlaybackControlsView.swift", "Spotiglass/Playback/SpotifyPlaybackBridge.swift", "Spotiglass/Services/SpotifyAPIClient+Home.swift",