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
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Build and safety checks
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: checks-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
strategy:
fail-fast: false
matrix:
os: [macos-14, macos-15-intel]
runs-on: ${{ matrix.os }}
timeout-minutes: 25
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: ./build.sh
- run: ./check.sh
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ xcuserdata/

# Editor / tool config
.claude/

# Local test binaries
.checks/
__pycache__/
Binary file added App/AppIcon.icns
Binary file not shown.
599 changes: 270 additions & 329 deletions App/AppState.swift

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions App/AudioObservation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import CoreAudio
import Foundation

/// Owns the exact block and queue required to remove a CoreAudio listener.
final class AudioObservation {
private let object: AudioObjectID
private var address: AudioObjectPropertyAddress
private let block: AudioObjectPropertyListenerBlock

init?(object: AudioObjectID = AudioObjectID(kAudioObjectSystemObject),
selector: AudioObjectPropertySelector, handler: @escaping @MainActor () -> Void) {
self.object = object
address = AudioObjectPropertyAddress(mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain)
block = { _, _ in MainActor.assumeIsolated { handler() } }
guard AudioObjectAddPropertyListenerBlock(object, &address, .main, block) == noErr else { return nil }
}

deinit { AudioObjectRemovePropertyListenerBlock(object, &address, .main, block) }
}
44 changes: 44 additions & 0 deletions App/AudioRecovery.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Foundation

/// A child owns the read end of a pipe. App termination (including SIGKILL)
/// closes the write end, waking it to restore the output without a login item.
final class AudioRecovery {
private var process: Process?
private var input: FileHandle?

var isRunning: Bool { process?.isRunning == true }

func arm(preferredUID: String?) throws {
disarm()
guard let executable = Bundle.main.url(forAuxiliaryExecutable: "MacStereoFixRecovery") else {
throw CocoaError(.fileNoSuchFile)
}
let child = Process()
let pipe = Pipe()
child.executableURL = executable
child.arguments = [preferredUID ?? ""]
child.standardInput = pipe
child.standardOutput = FileHandle.nullDevice
child.standardError = FileHandle.nullDevice
try child.run()
_ = fcntl(pipe.fileHandleForWriting.fileDescriptor, F_SETNOSIGPIPE, 1)
process = child
input = pipe.fileHandleForWriting
// Process.run returns only after a successful exec; EOF is retained if
// the parent exits before the child starts reading.
}

func disarm() {
if let input {
try? input.write(contentsOf: Data("disarm\n".utf8))
try? input.close()
}
input = nil
process = nil
}

deinit {
// No disarm here: unexpected owner destruction must trigger recovery.
try? input?.close()
}
}
Loading