Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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 }')
Expand Down
171 changes: 171 additions & 0 deletions Spotiglass.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions Spotiglass/App/SpotiglassApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 0 additions & 69 deletions Spotiglass/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
46 changes: 36 additions & 10 deletions Spotiglass/Playback/AudioEqualizerEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
)
}
}

Expand All @@ -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
)
}
}

Expand Down Expand Up @@ -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)
}
}

Expand Down
141 changes: 141 additions & 0 deletions Spotiglass/Playback/EqualizerDriverInstallPolicy.swift
Original file line number Diff line number Diff line change
@@ -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..<count {
let left = index < lhs.count ? lhs[index] : 0
let right = index < rhs.count ? rhs[index] : 0
if left != right { return left < right }
}
return false
}

private static func parseComponents(_ rawValue: String) -> [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)
}
}
Loading
Loading