Phase 9: Einrichtung, Autostart, Notarisierung — und die Mixer-Optik
Die Einrichtung beim ersten Start erklärt jede der vier Sachen einzeln und fragt jede einzeln an. Vier Systemdialoge hintereinander wären der übliche Weg und der falsche: wer nicht weiß wofür, klickt viermal „Nicht erlauben", und danach fragt macOS nie wieder. Sie kommt beim ersten Start immer, auch wenn zufällig schon alles erlaubt ist — sie erklärt auch den Helfer und den Autostart. Beim Autostart ist `requiresApproval` der Zustand, der weh tut. Wer das Anmeldeobjekt in den Systemeinstellungen abgeschaltet hat, kann in Onyx auf den Schalter drücken, so oft er will. Als „aus" angezeigt führt das in eine Schleife, also steht dort jetzt, wo es weitergeht. Der Mixer verliert den Schalter je Programm. Zehn Mini-Schalter untereinander sind das, was den Eindruck macht; am Regler zu ziehen ist ohnehin die Entscheidung, dieses Programm zu regeln, und der große Ausschalter stellt alles zurück. Der Regler ist selbst gezeichnet — der Systemregler bringt sein eigenes Erscheinungsbild mit und kann nicht zeigen, worauf es hier ankommt: den Pegel und die Grenze bei 100 %. Der Pegel wird im selben Durchlauf gemessen, in dem verstärkt wird, und über `Atomic` weitergereicht. Eine gewöhnliche Eigenschaft wäre ein Wettlauf, eine Sperre im Audiothread der klassische Weg zu Aussetzern. Dabei fällt das `Date()` aus dem Echtzeitpfad, das dort nie hingehört hat. Zwei Funde beim Bauen des Release-Wegs, beide hätten erst nach dem Hochladen zur Ablehnung geführt: der Helfer wurde ohne sicheren Zeitstempel signiert, und das Adapter-Framework ad-hoc. Beides gilt jetzt nur noch im Debug. Scripts/release.sh prüft Zertifikat und Notarisierungsprofil vorweg, statt nach zehn Minuten Bauen zu scheitern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,9 @@ public final class AudioMixer {
|
||||
private var taps: [String: ProcessTap] = [:]
|
||||
private var refreshTimer: Timer?
|
||||
private var watchdogTimer: Timer?
|
||||
private var levelTimer: Timer?
|
||||
/// Der letzte Ausschlag je Programm, 0…1.
|
||||
public private(set) var levels: [String: Double] = [:]
|
||||
private let defaults: UserDefaults
|
||||
/// Wann ein Prozess zuletzt Ton ausgegeben hat. Ohne dieses Gedächtnis
|
||||
/// verschwindet ein pausiertes Programm sofort aus der Liste.
|
||||
@@ -75,8 +78,10 @@ public final class AudioMixer {
|
||||
isEnabled = false
|
||||
refreshTimer?.invalidate(); refreshTimer = nil
|
||||
watchdogTimer?.invalidate(); watchdogTimer = nil
|
||||
levelTimer?.invalidate(); levelTimer = nil
|
||||
for tap in taps.values { tap.stop() }
|
||||
taps.removeAll()
|
||||
levels = [:]
|
||||
lastError = nil
|
||||
log.notice("Mixer ausgeschaltet — Tonweg unverändert")
|
||||
}
|
||||
@@ -99,6 +104,7 @@ public final class AudioMixer {
|
||||
isManaged: managed.contains(process.bundleID))
|
||||
}
|
||||
syncTaps()
|
||||
startLevelTimerIfNeeded()
|
||||
}
|
||||
|
||||
/// Legt Taps an und ab, sodass sie zu `managed` und den laufenden
|
||||
@@ -146,7 +152,7 @@ public final class AudioMixer {
|
||||
private func checkForStalledTaps() {
|
||||
for (bundleID, tap) in taps {
|
||||
guard tap.isRunning,
|
||||
Date().timeIntervalSince(tap.lastAudioAt) > 30,
|
||||
tap.secondsSinceAudio > 30,
|
||||
processes.first(where: { $0.bundleID == bundleID })?.isPlaying == true
|
||||
else { continue }
|
||||
|
||||
@@ -181,6 +187,41 @@ public final class AudioMixer {
|
||||
|
||||
public func isManaged(_ bundleID: String) -> Bool { managed.contains(bundleID) }
|
||||
|
||||
/// Der Ausschlag eines Programms, 0…1 — für die Pegelanzeige.
|
||||
public func level(for bundleID: String) -> Double { levels[bundleID] ?? 0 }
|
||||
|
||||
/// Holt die Ausschläge ab.
|
||||
///
|
||||
/// Läuft nur, solange überhaupt Taps bestehen: ohne abgegriffenes Programm
|
||||
/// gibt es nichts zu messen, und ein Zeitgeber, der 15-mal pro Sekunde
|
||||
/// nichts tut, gehört nicht in eine App, die im Hintergrund ruhig sein soll.
|
||||
private func sampleLevels() {
|
||||
guard !taps.isEmpty else {
|
||||
if !levels.isEmpty { levels = [:] }
|
||||
levelTimer?.invalidate()
|
||||
levelTimer = nil
|
||||
return
|
||||
}
|
||||
var next: [String: Double] = [:]
|
||||
for (bundleID, tap) in taps {
|
||||
let peak = Double(tap.takePeak())
|
||||
let previous = levels[bundleID] ?? 0
|
||||
// Schnell hoch, langsam runter. Ein Pegel, der jedem Puffer folgt,
|
||||
// flackert; einer, der nur fällt, verpasst die Spitzen.
|
||||
next[bundleID] = peak > previous ? peak : previous * 0.72
|
||||
}
|
||||
levels = next
|
||||
}
|
||||
|
||||
private func startLevelTimerIfNeeded() {
|
||||
guard levelTimer == nil, !taps.isEmpty else { return }
|
||||
let timer = Timer(timeInterval: 1.0 / 15, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in self?.sampleLevels() }
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
levelTimer = timer
|
||||
}
|
||||
|
||||
public func setManaged(_ isManaged: Bool, for bundleID: String) {
|
||||
if isManaged { managed.insert(bundleID) } else { managed.remove(bundleID) }
|
||||
persist()
|
||||
|
||||
Reference in New Issue
Block a user