Phase 0: Repo-Gerüst und vier Verifikations-Spikes

Vier Annahmen des Plans vor jedem Implementierungscode geprüft:

A (SMC):  Lüftersteuerung bestätigt — F0md/F1md (klein!) als Auto/Manuell-
          Umschalter, F0Tg als Ziel, Grenzen aus F0Mn/F0Mx. SMC-Integer sind
          little-endian, nicht big-endian wie im verbreiteten Intel-Beispielcode.
          Ladelimit: CHWA/CH0B/CH0C/bfE0/bfF0 existieren auf Mac17,9 nicht.
          Key noch nicht identifiziert, deshalb smc-diff.sh für eine
          Differenzmessung ohne jeden Schreibzugriff.
B (Media): mediaremote-adapter läuft unter 26.6.1, aus dem Quellcode gebaut.
          Liefert vollständige Metadaten inkl. Cover aus Google Chrome — der
          Fall, den AppleScript nicht erreicht.
C (Audio): Process Tap + privates Aggregate-Device liefern echte Samples
          (469 Callbacks/5 s, 0 stille Puffer). Konflikt gefunden: SoundSource
          6.1.1 mit ARK.driver ist bereits installiert.
D (Group): App-Group-Container ist aus einem nicht-sandboxed Prozess erreichbar.
          Damit trägt die Sandbox-Entscheidung, die Bridge-App entfällt.

Spikes sind Wegwerfcode zur Verifikation, kein Produktionscode.
This commit is contained in:
Guido Schmit
2026-08-10 16:47:54 +02:00
commit 41ee3cc174
11 changed files with 1085 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.DS_Store
build/
.build/
DerivedData/
*.xcuserstate
xcuserdata/
*.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
# Vendor-Artefakte werden beim Build erzeugt, nicht eingecheckt
Onyx/Resources/Vendor/MediaRemoteAdapter.framework/

View File

@@ -0,0 +1,60 @@
// Spike D App-Group-Container aus einem NICHT sandboxed Prozess
//
// Frage: liefert containerURL(forSecurityApplicationGroupIdentifier:) einen Pfad,
// wenn der Prozess die App-Group-Entitlement trägt, aber NICHT sandboxed ist?
// Davon hängt ab, ob Onyx den Calendarr-Snapshot direkt lesen kann oder eine
// sandboxed Bridge-App braucht.
//
// Bauen und signieren: Spikes/build-appgroup-probe.sh
import Foundation
let groupID = "PP34X97WS3.group.com.scarriffleservices.calendarr"
print("App-Group-Probe")
print("Gruppe: \(groupID)")
print("Sandboxed: \(ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil ? "ja" : "nein")")
print(String(repeating: "", count: 64))
guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID) else {
print("ERGEBNIS: nil")
print()
print("Der Container ist nicht erreichbar. Mögliche Ursachen:")
print(" 1. Entitlement fehlt oder Signatur ungültig")
print(" 2. Team-Präfix stimmt nicht byte-genau")
print(" 3. App-Group ohne Sandbox wird doch nicht gewährt → Bridge-App nötig")
exit(1)
}
print("ERGEBNIS: \(url.path)")
print("Existiert: \(FileManager.default.fileExists(atPath: url.path) ? "ja" : "nein (Calendarr lief hier noch nie)")")
// Schreibprobe der Container muss beschreibbar sein, sonst wäre selbst ein
// eigener Cache dort unmöglich. Berührt widget-cache.json NICHT.
let probe = url.appendingPathComponent(".onyx-probe")
do {
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
try Data("ok".utf8).write(to: probe)
try FileManager.default.removeItem(at: probe)
print("Schreibzugriff: ja")
} catch {
print("Schreibzugriff: nein (\(error.localizedDescription))")
}
// Was liegt drin? Nur auflisten, nichts öffnen und nichts verändern.
if let items = try? FileManager.default.contentsOfDirectory(atPath: url.path), !items.isEmpty {
print("\nInhalt:")
for item in items.sorted() { print(" \(item)") }
} else {
print("\nContainer ist leer — Calendarr-Mac-App noch nicht gestartet.")
print("Das ist der Zustand .neverWritten, den das Kalender-Widget anzeigen muss.")
}
// Die Darwin-Notification, auf die Onyx später hört. Nur registrieren, um zu
// bestätigen, dass das ohne Sandbox funktioniert.
let name = "com.scarriffleservices.calendarr.snapshot-changed"
CFNotificationCenterAddObserver(
CFNotificationCenterGetDarwinNotifyCenter(), nil,
{ _, _, _, _, _ in print("Darwin-Notification empfangen") },
name as CFString, nil, .deliverImmediately)
print("\nDarwin-Observer für '\(name)' registriert (ohne Fehler).")

230
Spikes/audiotap-probe.swift Normal file
View File

@@ -0,0 +1,230 @@
// Spike C Core Audio Process Taps auf M5 Pro / macOS 26
//
// Fragen:
// 1. Liefert kAudioHardwarePropertyProcessObjectList die Audio-Prozesse?
// 2. Lässt sich pro Prozess ein Tap erzeugen (AudioHardwareCreateProcessTap)?
// 3. Lässt sich ein privates Aggregate-Device mit diesem Tap bauen?
// 4. Liefert ein IOProc darauf tatsächlich Samples?
//
// Der Tap wird mit muteBehavior = .unmuted angelegt dieser Spike hört nur zu und
// verändert NICHTS an der Wiedergabe. Der stummschaltende Modus, den der spätere
// Mixer braucht, wird hier bewusst nicht benutzt.
//
// Bauen: Spikes/build-audiotap-probe.sh
import Foundation
import AppKit
import AVFoundation
import CoreAudio
import AudioToolbox
// MARK: - Property-Hilfen
func address(_ selector: AudioObjectPropertySelector,
_ scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal)
-> AudioObjectPropertyAddress {
AudioObjectPropertyAddress(mSelector: selector, mScope: scope,
mElement: kAudioObjectPropertyElementMain)
}
func readArray<T>(_ object: AudioObjectID, _ addr: AudioObjectPropertyAddress, _ type: T.Type) -> [T] {
var addr = addr
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(object, &addr, 0, nil, &size) == noErr, size > 0 else { return [] }
let count = Int(size) / MemoryLayout<T>.stride
let raw = UnsafeMutableRawPointer.allocate(byteCount: Int(size),
alignment: MemoryLayout<T>.alignment)
defer { raw.deallocate() }
guard AudioObjectGetPropertyData(object, &addr, 0, nil, &size, raw) == noErr else { return [] }
return Array(UnsafeBufferPointer(start: raw.assumingMemoryBound(to: T.self), count: count))
}
func readValue<T>(_ object: AudioObjectID, _ addr: AudioObjectPropertyAddress, _ initial: T) -> T? {
var addr = addr
var value = initial
var size = UInt32(MemoryLayout<T>.size)
guard AudioObjectGetPropertyData(object, &addr, 0, nil, &size, &value) == noErr else { return nil }
return value
}
func readString(_ object: AudioObjectID, _ addr: AudioObjectPropertyAddress) -> String? {
var addr = addr
var cf: CFString? = nil
var size = UInt32(MemoryLayout<CFString?>.size)
let status = withUnsafeMutablePointer(to: &cf) {
AudioObjectGetPropertyData(object, &addr, 0, nil, &size, $0)
}
guard status == noErr, let cf else { return nil }
return cf as String
}
let system = AudioObjectID(kAudioObjectSystemObject)
print("Core-Audio-Process-Tap-Probe")
print("macOS \(ProcessInfo.processInfo.operatingSystemVersionString)")
print(String(repeating: "", count: 68))
// MARK: - 1. Audio-Prozesse auflisten
let processes = readArray(system, address(kAudioHardwarePropertyProcessObjectList), AudioObjectID.self)
print("\n1. Audio-Prozesse: \(processes.count)")
struct AudioProc {
let objectID: AudioObjectID
let pid: pid_t
let bundleID: String
let running: Bool
var name: String {
guard let app = NSRunningApplication(processIdentifier: pid) else { return bundleID }
return app.localizedName ?? bundleID
}
}
var procs: [AudioProc] = []
for obj in processes {
let pid = readValue(obj, address(kAudioProcessPropertyPID), pid_t(0)) ?? -1
let bundle = readString(obj, address(kAudioProcessPropertyBundleID)) ?? ""
let running = (readValue(obj, address(kAudioProcessPropertyIsRunningOutput), UInt32(0)) ?? 0) != 0
procs.append(AudioProc(objectID: obj, pid: pid, bundleID: bundle, running: running))
}
for p in procs.sorted(by: { $0.running && !$1.running }) {
let mark = p.running ? "▶ SPIELT AB" : " still"
print(" \(mark) pid \(String(p.pid).padding(toLength: 6, withPad: " ", startingAt: 0)) \(p.bundleID)")
}
guard let target = procs.first(where: { $0.running }) else {
print("\nKein Prozess gibt gerade Audio aus — bitte etwas abspielen und erneut starten.")
exit(2)
}
print("\n Ziel: \(target.bundleID) (pid \(target.pid))")
// MARK: - 2. Tap erzeugen
print("\n2. Process Tap")
let tapDescription = CATapDescription(stereoMixdownOfProcesses: [target.objectID])
tapDescription.uuid = UUID()
tapDescription.name = "Onyx Spike Tap"
tapDescription.isPrivate = true
// Bewusst NICHT .mutedWhenTapped dieser Spike verändert die Wiedergabe nicht.
tapDescription.muteBehavior = .unmuted
var tapID = AudioObjectID(kAudioObjectUnknown)
let tapStatus = AudioHardwareCreateProcessTap(tapDescription, &tapID)
guard tapStatus == noErr else {
print(" FEHLGESCHLAGEN: OSStatus \(tapStatus)")
if tapStatus == 1751737219 { // 'nope' kAudioHardwareIllegalOperationError
print(" → Vermutlich fehlt die TCC-Berechtigung für Systemaudioaufnahme.")
print(" Systemeinstellungen → Datenschutz & Sicherheit → Audioaufnahme")
}
exit(1)
}
print(" erzeugt, Tap-Objekt \(tapID)")
defer { AudioHardwareDestroyProcessTap(tapID) }
let tapFormat = readValue(tapID, address(kAudioTapPropertyFormat), AudioStreamBasicDescription())
if let f = tapFormat {
print(" Format: \(Int(f.mSampleRate)) Hz, \(f.mChannelsPerFrame) Kanäle, \(f.mBitsPerChannel) bit")
}
// MARK: - 3. Privates Aggregate-Device
print("\n3. Privates Aggregate-Device")
guard let outputDevice = readValue(system, address(kAudioHardwarePropertyDefaultOutputDevice),
AudioObjectID(kAudioObjectUnknown)),
let outputUID = readString(outputDevice, address(kAudioDevicePropertyDeviceUID)) else {
print(" Standard-Ausgabegerät nicht ermittelbar")
exit(1)
}
print(" Ausgabegerät: \(readString(outputDevice, address(kAudioObjectPropertyName)) ?? outputUID)")
let aggregateUID = UUID().uuidString
let description: [String: Any] = [
kAudioAggregateDeviceNameKey: "Onyx Spike Aggregate",
kAudioAggregateDeviceUIDKey: aggregateUID,
kAudioAggregateDeviceIsPrivateKey: true,
kAudioAggregateDeviceIsStackedKey: false,
kAudioAggregateDeviceMainSubDeviceKey: outputUID,
kAudioAggregateDeviceSubDeviceListKey: [[kAudioSubDeviceUIDKey: outputUID]],
kAudioAggregateDeviceTapListKey: [[
kAudioSubTapUIDKey: tapDescription.uuid.uuidString,
kAudioSubTapDriftCompensationKey: true,
]],
]
var aggregateID = AudioObjectID(kAudioObjectUnknown)
let aggStatus = AudioHardwareCreateAggregateDevice(description as CFDictionary, &aggregateID)
guard aggStatus == noErr else {
print(" FEHLGESCHLAGEN: OSStatus \(aggStatus)")
exit(1)
}
print(" erzeugt, Device \(aggregateID), UID \(aggregateUID)")
defer { AudioHardwareDestroyAggregateDevice(aggregateID) }
// MARK: - 4. IOProc kommen Samples an?
print("\n4. IOProc (5 s messen)")
final class Meter: @unchecked Sendable {
var callbacks = 0
var frames = 0
var peak: Float = 0
var silentBuffers = 0
let lock = NSLock()
}
let meter = Meter()
var procID: AudioDeviceIOProcID?
let ioStatus = AudioDeviceCreateIOProcIDWithBlock(&procID, aggregateID, nil) {
_, inputData, _, _, _ in
let buffers = UnsafeMutableAudioBufferListPointer(UnsafeMutablePointer(mutating: inputData))
var localPeak: Float = 0
var frameCount = 0
for buffer in buffers {
guard let data = buffer.mData else { continue }
let count = Int(buffer.mDataByteSize) / MemoryLayout<Float>.size
frameCount += count
let samples = data.assumingMemoryBound(to: Float.self)
for i in 0..<count { localPeak = max(localPeak, abs(samples[i])) }
}
meter.lock.lock()
meter.callbacks += 1
meter.frames += frameCount
meter.peak = max(meter.peak, localPeak)
if localPeak == 0 { meter.silentBuffers += 1 }
meter.lock.unlock()
}
guard ioStatus == noErr, let procID else {
print(" IOProc-Erzeugung fehlgeschlagen: \(ioStatus)")
exit(1)
}
defer { AudioDeviceDestroyIOProcID(aggregateID, procID) }
guard AudioDeviceStart(aggregateID, procID) == noErr else {
print(" AudioDeviceStart fehlgeschlagen")
exit(1)
}
Thread.sleep(forTimeInterval: 5)
AudioDeviceStop(aggregateID, procID)
meter.lock.lock()
let (cb, fr, pk, silent) = (meter.callbacks, meter.frames, meter.peak, meter.silentBuffers)
meter.lock.unlock()
print(" Callbacks: \(cb) (\(cb / 5)/s)")
print(" Frames: \(fr)")
print(" Spitzenpegel: \(String(format: "%.4f", pk))")
print(" Stille Puffer: \(silent) von \(cb)")
print("\n" + String(repeating: "", count: 68))
if cb > 0 && pk > 0 {
print("ERGEBNIS: Tap liefert echte Samples. Per-App-Mixer ist umsetzbar.")
} else if cb > 0 {
print("ERGEBNIS: Callbacks kommen, aber nur Stille — spielte das Ziel wirklich ab?")
} else {
print("ERGEBNIS: keine Callbacks. Aggregate-Device liefert nichts.")
}

36
Spikes/build-appgroup-probe.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
# Baut Spike D und signiert ihn mit der App-Group-Entitlement.
set -euo pipefail
cd "$(dirname "$0")/.."
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
OUT=/tmp/appgroup-probe
ENT=/tmp/appgroup-probe.entitlements
IDENTITY="${IDENTITY:-Apple Development: guidoschmit@guidoschmit.com (F586278766)}"
cat > "$ENT" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>PP34X97WS3.group.com.scarriffleservices.calendarr</string>
</array>
</dict>
</plist>
PLIST
echo "→ kompiliere"
xcrun swiftc -O Spikes/appgroup-probe.swift -o "$OUT"
echo "→ signiere mit: $IDENTITY"
codesign --force --options runtime --entitlements "$ENT" --sign "$IDENTITY" "$OUT"
echo "→ Signatur:"
codesign -d --entitlements - "$OUT" 2>&1 | tail -n +2
echo
echo "→ Lauf:"
"$OUT"

View File

@@ -0,0 +1,74 @@
#!/bin/bash
# Spike B — mediaremote-adapter aus dem Quellcode bauen (ohne cmake)
#
# Repliziert das CMakeLists.txt des Projekts mit clang. Erzeugt das
# Framework-Bundle, das die Perl-Trampolin-Datei per DynaLoader lädt.
#
# Quelle: https://github.com/ungive/mediaremote-adapter (BSD-3-Clause)
set -euo pipefail
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
SRC="${SRC:-/tmp/mra}"
OUT="${OUT:-/tmp/mra-build}"
NAME=MediaRemoteAdapter
FW="$OUT/$NAME.framework"
[[ -d "$SRC" ]] || { echo "Quellen fehlen unter $SRC" >&2; exit 1; }
rm -rf "$OUT"
mkdir -p "$FW/Versions/A/Headers" "$FW/Versions/A/Resources"
SOURCES=(
"$SRC"/src/adapter/{env,get,globals,keys,now_playing,repeat,seek,send,shuffle,speed,stream,test}.m
"$SRC"/src/private/MediaRemote.m
"$SRC"/src/utility/{Debounce,helpers}.m
)
echo "→ Framework-Binary"
xcrun clang -dynamiclib -fobjc-arc -fvisibility=default -O2 \
-arch arm64 \
-I"$SRC/include" -I"$SRC/src" \
-framework Foundation -framework AppKit -framework UniformTypeIdentifiers \
-install_name "@rpath/$NAME.framework/Versions/A/$NAME" \
-o "$FW/Versions/A/$NAME" \
"${SOURCES[@]}"
cp "$SRC/include/$NAME.h" "$FW/Versions/A/Headers/"
cat > "$FW/Versions/A/Resources/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key><string>$NAME</string>
<key>CFBundleIdentifier</key><string>com.vandenbe.$NAME</string>
<key>CFBundleName</key><string>$NAME</string>
<key>CFBundlePackageType</key><string>FMWK</string>
<key>CFBundleShortVersionString</key><string>0.1</string>
<key>CFBundleVersion</key><string>0.1.0</string>
</dict>
</plist>
PLIST
ln -sf A "$FW/Versions/Current"
ln -sf "Versions/Current/$NAME" "$FW/$NAME"
ln -sf Versions/Current/Headers "$FW/Headers"
ln -sf Versions/Current/Resources "$FW/Resources"
echo "→ Test-Client"
xcrun clang -fobjc-arc -O2 -arch arm64 \
-I"$SRC/src/test" \
-framework Foundation -framework MediaPlayer \
-o "$OUT/${NAME}TestClient" \
"$SRC"/src/test/{main,NowPlayingTest}.m
echo "→ ad-hoc signieren"
codesign --force --deep --sign - "$FW"
codesign --force --sign - "$OUT/${NAME}TestClient"
cp "$SRC/bin/mediaremote-adapter.pl" "$OUT/"
echo
echo "Fertig: $OUT"
ls -la "$OUT"

62
Spikes/smc-diff.sh Executable file
View File

@@ -0,0 +1,62 @@
#!/bin/bash
# Spike A, Teil 2 — SMC-Differenzmessung
#
# Identifiziert den Ladelimit-Key, ohne je in den SMC zu schreiben: zwei
# vollständige Dumps vergleichen, zwischen denen macOS selbst die Ladegrenze
# umgestellt hat.
#
# Ablauf:
# ./smc-diff.sh vorher # Ladegrenze AUS
# (in den Systemeinstellungen umschalten, ~30 s warten)
# ./smc-diff.sh nachher # Ladegrenze AN
# ./smc-diff.sh vergleich
#
# Lesend. Dieses Skript ruft nur smc-dump auf, das keinen Schreibpfad besitzt.
set -euo pipefail
DIR="${TMPDIR:-/tmp}/onyx-smc-diff"
DUMP="${DUMP:-/tmp/smc-dump}"
mkdir -p "$DIR"
if [[ ! -x "$DUMP" ]]; then
echo "smc-dump nicht gefunden unter $DUMP" >&2
echo "Bauen mit: xcrun swiftc -O Spikes/smc-dump.swift -o /tmp/smc-dump" >&2
exit 1
fi
# Werte, die sich ohnehin ständig ändern — sie würden die Differenz zumüllen.
# Temperaturen (T*), Lüfter (F*), Leistung (P*), Spannungen/Ströme und Zähler.
RAUSCHEN='^ (T|F[0-9]|P[A-Z]|V|I|B0A|B0R|BRSC|CH0V|CHA|CHB|CHI[0-9]|CHNI|MSS|D[0-9])'
snapshot() {
"$DUMP" --all | awk '/ALLE KEYS/,0' | grep -E '^ [A-Za-z#]' \
| grep -vE "$RAUSCHEN" | sort > "$DIR/$1.txt"
echo "$DIR/$1.txt ($(wc -l < "$DIR/$1.txt" | tr -d ' ') Keys nach Rauschfilter)"
}
case "${1:-}" in
vorher|nachher)
echo "Nehme Snapshot '$1' auf …"
snapshot "$1"
[[ "$1" == "vorher" ]] && echo "Jetzt die Ladegrenze in den Systemeinstellungen umschalten, dann: $0 nachher"
;;
vergleich)
for f in vorher nachher; do
[[ -f "$DIR/$f.txt" ]] || { echo "Snapshot '$f' fehlt." >&2; exit 1; }
done
echo "Geänderte Keys zwischen 'vorher' und 'nachher':"
echo "────────────────────────────────────────────────────────────────"
if diff "$DIR/vorher.txt" "$DIR/nachher.txt" | grep -E '^[<>]'; then
echo
echo "Ein Key, der hier auftaucht und nach CH*/bf*/AC* aussieht, ist der Kandidat."
else
echo "Keine Unterschiede — wurde die Einstellung wirklich umgeschaltet?"
echo "Manche Änderungen brauchen bis zu einer Minute, bis der SMC sie übernimmt."
fi
;;
*)
sed -n '2,14p' "$0" | sed 's|^# \{0,1\}||'
exit 1
;;
esac

333
Spikes/smc-dump.swift Normal file
View File

@@ -0,0 +1,333 @@
// Spike A SMC-Key-Dump für Onyx
//
// Zweck: herausfinden, welche Lüfter- und Ladesteuerungs-Keys auf diesem Mac
// (Mac17,9 / M5 Pro) tatsächlich existieren, welchen Typ und welche Größe sie
// haben und welche Werte gerade anliegen.
//
// AUSSCHLIESSLICH LESEND. Dieser Spike enthält bewusst keinen Schreibpfad
// kSMCWriteKey wird nirgends verwendet.
//
// Bauen: xcrun swiftc -O Spikes/smc-dump.swift -o /tmp/smc-dump
// Nutzen: /tmp/smc-dump Zusammenfassung der interessanten Keys
// /tmp/smc-dump --all vollständiger Dump aller Keys
import Foundation
import IOKit
// MARK: - Layout des AppleSMC-UserClients
struct SMCVersion {
var major: UInt8 = 0
var minor: UInt8 = 0
var build: UInt8 = 0
var reserved: UInt8 = 0
var release: UInt16 = 0
}
struct SMCPLimitData {
var version: UInt16 = 0
var length: UInt16 = 0
var cpuPLimit: UInt32 = 0
var gpuPLimit: UInt32 = 0
var memPLimit: UInt32 = 0
}
struct SMCKeyInfoData {
var dataSize: UInt32 = 0
var dataType: UInt32 = 0
var dataAttributes: UInt8 = 0
}
struct SMCParamStruct {
var key: UInt32 = 0
var vers = SMCVersion()
var pLimitData = SMCPLimitData()
var keyInfo = SMCKeyInfoData()
var padding: UInt16 = 0
var result: UInt8 = 0
var status: UInt8 = 0
var data8: UInt8 = 0
var data32: UInt32 = 0
var bytes: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8) =
(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
}
private let kSMCHandleYPCEvent: UInt32 = 2
private let kSMCReadKey: UInt8 = 5
private let kSMCGetKeyFromIndex: UInt8 = 8
private let kSMCGetKeyInfo: UInt8 = 9
// MARK: - FourCC-Hilfen
func fourCC(_ s: String) -> UInt32 {
var result: UInt32 = 0
for ch in s.utf8.prefix(4) { result = result << 8 | UInt32(ch) }
return result
}
func fourCCString(_ v: UInt32) -> String {
let bytes = [UInt8((v >> 24) & 0xFF), UInt8((v >> 16) & 0xFF),
UInt8((v >> 8) & 0xFF), UInt8(v & 0xFF)]
return String(bytes: bytes, encoding: .ascii) ?? "????"
}
// MARK: - Verbindung
struct SMCError: Error, CustomStringConvertible {
let description: String
}
final class SMC {
private var connection: io_connect_t = 0
init() throws {
let service = IOServiceGetMatchingService(kIOMainPortDefault,
IOServiceMatching("AppleSMC"))
guard service != 0 else { throw SMCError(description: "AppleSMC-Service nicht gefunden") }
defer { IOObjectRelease(service) }
let result = IOServiceOpen(service, mach_task_self_, 0, &connection)
guard result == kIOReturnSuccess else {
throw SMCError(description: "IOServiceOpen fehlgeschlagen: 0x\(String(result, radix: 16))")
}
}
deinit {
if connection != 0 { IOServiceClose(connection) }
}
private func call(_ input: SMCParamStruct) throws -> SMCParamStruct {
var input = input
var output = SMCParamStruct()
var outputSize = MemoryLayout<SMCParamStruct>.stride
let result = IOConnectCallStructMethod(connection, kSMCHandleYPCEvent,
&input, MemoryLayout<SMCParamStruct>.stride,
&output, &outputSize)
guard result == kIOReturnSuccess else {
throw SMCError(description: "IOConnectCallStructMethod: 0x\(String(result, radix: 16))")
}
// result == 132 heißt "Key existiert nicht" das ist ein erwarteter Zustand.
guard output.result == 0 else {
throw SMCError(description: "SMC-Status \(output.result)")
}
return output
}
func keyCount() throws -> UInt32 {
let value = try read(key: "#KEY")
return value.uint32
}
func key(atIndex index: UInt32) throws -> String {
var input = SMCParamStruct()
input.data8 = kSMCGetKeyFromIndex
input.data32 = index
return fourCCString(try call(input).key)
}
struct Value {
let key: String
let type: String
let size: UInt32
let bytes: [UInt8]
var uint32: UInt32 {
var v: UInt32 = 0
for b in bytes.prefix(4) { v = v << 8 | UInt32(b) }
return v
}
/// Best-effort-Interpretation anhand des SMC-Typs.
var decoded: String {
guard !bytes.isEmpty else { return "" }
switch type {
case "ui8 ", "ui16", "ui32", "ui64":
var v: UInt64 = 0
for b in bytes.prefix(Int(size)) { v = v << 8 | UInt64(b) }
return String(v)
case "si8 ":
return String(Int8(bitPattern: bytes[0]))
case "si16":
let v = Int16(bitPattern: UInt16(bytes[0]) << 8 | UInt16(bytes[1]))
return String(v)
case "flag":
return bytes[0] == 1 ? "true" : "false"
case "flt ":
guard bytes.count >= 4 else { return "" }
let bits = UInt32(bytes[3]) << 24 | UInt32(bytes[2]) << 16
| UInt32(bytes[1]) << 8 | UInt32(bytes[0])
return String(format: "%.2f", Float(bitPattern: bits))
case "fpe2":
guard bytes.count >= 2 else { return "" }
return String((UInt16(bytes[0]) << 8 | UInt16(bytes[1])) >> 2)
case "sp78":
guard bytes.count >= 2 else { return "" }
let raw = Int16(bitPattern: UInt16(bytes[0]) << 8 | UInt16(bytes[1]))
return String(format: "%.2f", Float(raw) / 256.0)
case "ch8*":
return String(bytes: bytes.prefix(Int(size)), encoding: .ascii)?
.trimmingCharacters(in: CharacterSet(charactersIn: "\0")) ?? ""
case "{fds":
// Fan-Descriptor-Struktur: die ersten 12 Byte sind der Fan-Name.
return String(bytes: bytes.prefix(12), encoding: .ascii)?
.trimmingCharacters(in: CharacterSet(charactersIn: "\0 ")) ?? ""
default:
return ""
}
}
var hex: String {
bytes.prefix(Int(size)).map { String(format: "%02X", $0) }.joined(separator: " ")
}
}
func read(key: String) throws -> Value {
var info = SMCParamStruct()
info.key = fourCC(key)
info.data8 = kSMCGetKeyInfo
let keyInfo = try call(info).keyInfo
var readCmd = SMCParamStruct()
readCmd.key = fourCC(key)
readCmd.keyInfo = keyInfo
readCmd.data8 = kSMCReadKey
let output = try call(readCmd)
let all = withUnsafeBytes(of: output.bytes) { Array($0) }
return Value(key: key,
type: fourCCString(keyInfo.dataType),
size: min(keyInfo.dataSize, 32),
bytes: Array(all.prefix(Int(min(keyInfo.dataSize, 32)))))
}
}
// MARK: - Ausgabe
func printRow(_ v: SMC.Value, note: String = "") {
let key = v.key.padding(toLength: 6, withPad: " ", startingAt: 0)
let type = v.type.padding(toLength: 6, withPad: " ", startingAt: 0)
let size = String(v.size).padding(toLength: 3, withPad: " ", startingAt: 0)
let dec = v.decoded.padding(toLength: 14, withPad: " ", startingAt: 0)
print(" \(key) \(type) \(size) \(dec) \(v.hex)\(note.isEmpty ? "" : "\(note)")")
}
func section(_ title: String) {
print("\n\(title)")
print(String(repeating: "", count: 72))
print(" KEY TYPE SZ WERT ROH")
}
let smc: SMC
do {
smc = try SMC()
} catch {
print("FEHLER: \(error)")
exit(1)
}
print("SMC-Dump — \(ProcessInfo.processInfo.hostName)")
var model = [CChar](repeating: 0, count: 64)
var modelSize = 64
sysctlbyname("hw.model", &model, &modelSize, nil, 0)
print("Modell: \(String(cString: model)) macOS \(ProcessInfo.processInfo.operatingSystemVersionString)")
print("Als root: \(getuid() == 0 ? "ja" : "nein")")
// --- Lüfter ---------------------------------------------------------------
section("LÜFTER")
var fanCount = 0
if let count = try? smc.read(key: "FNum") {
printRow(count, note: "Anzahl Lüfter")
fanCount = Int(count.uint32)
} else {
print(" FNum nicht lesbar — keine Lüfterzählung verfügbar")
}
// Pro Lüfter: Ist, Min, Max, Ziel, Modus (0 = auto, 1 = manuell), Beschreibung
let fanSuffixes: [(String, String)] = [
("Ac", "Ist-Drehzahl"), ("Mn", "Minimum"), ("Mx", "Maximum"),
("Tg", "Ziel-Drehzahl"), ("Md", "Modus 0=auto 1=manuell"), ("ID", "Beschreibung"),
]
for fan in 0..<max(fanCount, 2) {
for (suffix, note) in fanSuffixes {
if let v = try? smc.read(key: "F\(fan)\(suffix)") {
printRow(v, note: note)
}
}
}
if let force = try? smc.read(key: "FS! ") { printRow(force, note: "Force-Bits (Bitmaske manuell)") }
// --- Ladesteuerung --------------------------------------------------------
section("AKKU / LADESTEUERUNG")
let chargeKeys: [(String, String)] = [
("CHWA", "80%-Limit (neuere Firmware, flag)"),
("CHTE", "Ladeziel"),
("CH0B", "Laden aus (legacy)"),
("CH0C", "Laden aus (legacy)"),
("CH0I", "Netzteil trennen (legacy)"),
("CH0J", "Ladezustand"),
("CH0K", "Ladezustand"),
("bfD0", "Untergrenze (Firmware-Backend)"),
("bfE0", "Obergrenze (Firmware-Backend)"),
("bfF0", "Aktivierung (Firmware-Backend)"),
("ACLC", "AC-Ladelogik"),
("BCLM", "Ladelimit (Intel-Ära)"),
("BRSC", "Ladung roh"),
("B0RM", "Restkapazität mAh"),
("B0FC", "Volle Kapazität mAh"),
("B0AC", "Strom mA"),
("B0AV", "Spannung mV"),
("BC1V", "Ladeschlussspannung"),
]
var foundCharge = 0
for (key, note) in chargeKeys {
if let v = try? smc.read(key: key) { printRow(v, note: note); foundCharge += 1 }
}
if foundCharge == 0 { print(" (keiner der bekannten Lade-Keys vorhanden)") }
// --- Temperaturen ---------------------------------------------------------
section("TEMPERATUR-SENSOREN (Auswahl)")
let tempKeys = ["TC0P", "TC0D", "TC0E", "TC0F", "TG0P", "TG0D", "Tp01", "Tp05", "Tp09",
"Tp0D", "Tg05", "Tg0D", "Ts0P", "Ts1P", "TB0T", "TB1T", "TW0P", "TH0x"]
var foundTemp = 0
for key in tempKeys {
if let v = try? smc.read(key: key), v.size > 0 { printRow(v); foundTemp += 1 }
}
if foundTemp == 0 {
print(" Keine klassischen T-Keys — auf Apple Silicon liegen die Temperaturen")
print(" überwiegend hinter IOHIDEventSystemClient, nicht hinter SMC-Keys.")
}
// --- Leistung -------------------------------------------------------------
section("LEISTUNG")
for (key, note) in [("PSTR", "Systemleistung W"), ("PDTR", "DC-Eingang W"),
("PMVR", "Netzteil W"), ("PPBR", "Akku-Leistung W")] {
if let v = try? smc.read(key: key) { printRow(v, note: note) }
}
// --- Vollständiger Dump ---------------------------------------------------
if CommandLine.arguments.contains("--all") {
section("ALLE KEYS")
guard let total = try? smc.keyCount() else {
print(" Key-Anzahl nicht lesbar")
exit(0)
}
print(" (\(total) Keys insgesamt)\n")
for i in 0..<total {
guard let name = try? smc.key(atIndex: i), let v = try? smc.read(key: name) else { continue }
printRow(v)
}
} else {
if let total = try? smc.keyCount() {
print("\n\(total) Keys insgesamt. Vollständiger Dump mit --all")
}
}

90
docs/spikes/A-smc.md Normal file
View File

@@ -0,0 +1,90 @@
# Spike A — SMC-Keys auf Mac17,9 (M5 Pro)
Gemessen am 10.08.2026, macOS 26.6.1 (25G76), **ohne root**, ausschließlich lesend.
Werkzeug: `Spikes/smc-dump.swift`. 3486 Keys insgesamt.
## Ergebnis in einem Satz
**Lüftersteuerung: bestätigt und umsetzbar.** **Ladelimit: Key noch nicht identifiziert**
das Standardverfahren aus AlDente/batt greift auf dieser Maschine nicht, weil die dort benutzten
Keys schlicht nicht existieren.
## Wichtige Querbefunde
**SMC-Integer sind little-endian.** Meine erste BE-Dekodierung ergab Unsinn (Akkuspannung
31023 mV). LE ergibt konsistente Werte, zweifach gegengeprüft:
| Key | Roh | LE-Wert | Gegenprobe |
|---|---|---|---|
| `B0AV` | `AA 2F` | 12202 mV | plausible Zellspannung |
| `CH0V` | `AA 2F 00 00` | 12202 mV | identisch zu `B0AV` |
| `B0AC` | `85 FD` | 635 mA | entlädt; passt zu `PMVR` = 0 W (kein Netzteil) |
| `PPBR` | — | 12,13 W | ≈ 12,2 V × 0,64 A ✓ |
`flt`-Keys sind ebenfalls little-endian IEEE-754. Für die Implementierung heißt das:
**alle Mehrbyte-SMC-Werte little-endian lesen**, entgegen dem verbreiteten Beispielcode, der
big-endian annimmt (der stammt aus der Intel-Ära).
## Lüfter — vollständig nutzbar
Zwei Lüfter vorhanden (`FNum` = 2).
| Key | Typ | Wert | Bedeutung |
|---|---|---|---|
| `F0md` / `F1md` | ui8 | 0 | **Modus: 0 = auto, 1 = manuell** |
| `F0Tg` / `F1Tg` | flt | 2317 / 2502 | Ziel-Drehzahl |
| `F0Ac` / `F1Ac` | flt | 2321 / 2499 | Ist-Drehzahl |
| `F0Mn` / `F1Mn` | flt | 2317 | Minimum |
| `F0Mx` / `F1Mx` | flt | 7826 | Maximum |
| `F0St` / `F1St` | ui8 | 5 | Status |
| `FEna` | hex | 07 | Freigabe-Bitmaske |
**Korrektur zur Planannahme:** der Modus-Key heißt `F0md` **klein geschrieben**, nicht `F0Md`.
Mit der Großschreibung aus der Intel-Dokumentation findet man ihn nicht — genau deshalb lief
dieser Spike vor dem Implementierungscode.
`FS! ` (die alte Force-Bitmaske) existiert **nicht**. Der Weg ist also:
`F0md = 1``F0Tg = Wunschdrehzahl`, zurück auf Automatik mit `F0md = 0`.
Der Sicherheitsrahmen aus dem Plan lässt sich damit sauber umsetzen: `F0Mn`/`F0Mx` liefern
die harten Grenzen direkt aus der Firmware, und `F0md = 0` ist ein einzelner, immer
verfügbarer Rückfallbefehl.
## Ladelimit — noch offen
**Nicht vorhanden:** `CHWA`, `CH0B`, `CH0C`, `CH0I`, `CH0J`, `CH0K`, `BCLM`, `bfE0`, `bfF0`.
Damit scheiden sowohl das AlDente-Legacy-Verfahren (`CH0B`/`CH0C`) als auch das
80-%-Flag (`CHWA`) als auch das vollständige Firmware-Backend (`bfD0`/`bfE0`/`bfF0`) aus —
von letzterem existiert nur `bfD0` (hex, 2 Byte, `00 00`).
Vorhanden ist stattdessen ein umfangreicher `CH*`-Satz. Plausible Kandidaten:
| Key | Typ | Wert (LE) | Vermutung |
|---|---|---|---|
| `CHTE` | ui32 | 0 | Ladeziel — 0 könnte „kein Limit" heißen |
| `CHTL` | ui32 | 3600 | untere Schwelle? |
| `CHTU` | ui32 | 4100 | obere Schwelle? (mV je Zelle) |
| `CHBV` | ui32 | 4125 | Ladeschlussspannung mV |
| `CHIL` / `CHIO` | flag | false | Inhibit-Flags |
| `CHSE` / `CHST` / `CHRT` | ui8 | 0 | Status/Steuerung |
**Das bleibt Vermutung, und Vermutungen werden hier nicht geschrieben.** Ein falscher Wert im
Lade-Subsystem ist die eine Operation in diesem Projekt, die Hardware dauerhaft beschädigen kann.
### Nächster Schritt: Differenzmessung statt Raten
macOS hat selbst eine Ladebegrenzung (Systemeinstellungen → Batterie → Batteriezustand →
„Optimiertes Laden" bzw. das 80-%-Limit). Wird sie umgeschaltet, muss sich der zuständige
SMC-Key ändern. `Spikes/smc-diff.sh` nimmt zwei vollständige Dumps auf und zeigt die Differenz —
damit ist der Key **ohne einen einzigen Schreibzugriff** identifizierbar.
Bis dieser Test gelaufen ist, bleibt die Akkukarte reine Anzeige.
## Auswirkungen auf den Plan
1. Phase 6 wird geteilt: **Lüftersteuerung kann gebaut werden**, das Ladelimit wartet auf die
Differenzmessung.
2. Alle SMC-Werte werden little-endian gelesen — betrifft `MetricsProvider` und `OnyxHelper`.
3. `F0Mn`/`F0Mx` werden zur Laufzeit aus der Firmware gelesen statt fest verdrahtet.
4. Lüfterdrehzahlen sind `flt`, nicht `fpe2`.

View File

@@ -0,0 +1,81 @@
# Spike B — mediaremote-adapter unter macOS 26.6.1
Gemessen am 10.08.2026. Quelle aus dem Git geklont und **selbst kompiliert**, keine
Fremd-Binary geladen: `Spikes/build-mediaremote-adapter.sh` ersetzt das CMake-Setup
des Projekts durch einen direkten clang-Aufruf (cmake ist auf dieser Maschine nicht
installiert).
Projekt: [ungive/mediaremote-adapter](https://github.com/ungive/mediaremote-adapter), BSD-3-Clause,
~400 KB Objective-C plus ein Perl-Skript.
## Ergebnis: funktioniert, auch im schwierigsten Fall
```
perl mediaremote-adapter.pl <framework> <testclient> test → Exitcode 0
```
Exitcode 0 heißt: der Adapter ist berechtigt, MediaRemote zu benutzen. Apples Sperre aus
macOS 15.4 ist damit auf 26.6.1 weiterhin über den Perl-Trampolinweg umgehbar.
### `get` — vollständige Metadaten
Gelesen wurde eine laufende Wiedergabe in **Google Chrome** (YouTube). Das ist der wichtige
Befund: genau diese Quelle erreicht AppleScript nicht, sie ist der Grund für die
Adapter-Ebene überhaupt.
Gelieferte Felder:
| Feld | Beispiel |
|---|---|
| `title` | „Hanjos große Entdeckung in Thailand! \| Hanjo 2012" |
| `artist` | „FilowLIVE" |
| `album` | `""` |
| `duration` | 3051,04 s |
| `elapsedTime` | 432,06 s |
| `playbackRate` | 1 |
| `playing` | `true` |
| `bundleIdentifier` | `com.google.Chrome` |
| `processIdentifier` | 4525 |
| `artworkData` | JPEG, base64 |
| `artworkMimeType` | `image/jpeg` |
| `timestamp` | `2026-08-10T14:22:16Z` |
| `contentItemIdentifier` | UUID |
Das Cover kam sofort mit — die Warnung aus der Projekt-README („artwork often takes a bit of
time") traf hier nicht zu, ist aber weiterhin einzuplanen.
### `stream` — der Modus, den Onyx benutzt
Zeilenweises JSON auf stdout:
```json
{"type":"data","diff":false,"payload":{}}
{"type":"data","diff":false,"payload":{"title":"…","elapsedTime":432.06,}}
```
Erst ein leerer Payload, dann der vollständige. `diff` unterscheidet Voll- von
Teilaktualisierung — der Parser in `MediaProvider` muss beide Fälle behandeln und darf bei
einem leeren Payload **nicht** auf „nichts läuft" schließen.
`elapsedTime` wird nicht sekündlich nachgeliefert. Die Fortschrittsanzeige muss zwischen den
Ereignissen lokal hochzählen, ausgehend von `elapsedTime` + `timestamp` + `playbackRate`,
sonst steht der Scrubber still.
## Nicht getestet
Die Steuerbefehle (`send`, `seek`, `shuffle`, `repeat`, `speed`) wurden **absichtlich nicht**
ausgeführt — sie hätten die laufende Wiedergabe des Nutzers angehalten oder verstellt. Die
Prüfung erfolgt in Phase 4 an einer eigens gestarteten Wiedergabe.
## Auswirkungen auf den Plan
1. Die Kaskade aus dem Plan bleibt, aber die Gewichtung verschiebt sich: der Adapter deckt
mehr ab als erwartet, AppleScript ist reiner Rückfall.
2. Der Adapter wird **aus dem Quellcode mitgebaut**, nicht als Fremd-Binary eingebunden.
`Spikes/build-mediaremote-adapter.sh` wandert dafür als Build-Phase ins Xcode-Projekt.
Vorteil: kein blind eingebundenes Binärartefakt, und die Ad-hoc-Signatur passt zum
restlichen Bundle.
3. Die Perl-Datei und das Framework kommen nach `Onyx/Resources/Vendor/` und werden
**nicht** gelinkt (sonst greift Apples Entitlement-Prüfung im eigenen Prozess).
4. `disable-library-validation` in den Entitlements ist bestätigt nötig — das Framework ist
ad-hoc signiert, der Host wird es mit Team-Zertifikat sein.

64
docs/spikes/C-audiotap.md Normal file
View File

@@ -0,0 +1,64 @@
# Spike C — Core Audio Process Taps auf M5 Pro / macOS 26.6.1
Gemessen am 10.08.2026. Werkzeug: `Spikes/audiotap-probe.swift`, signiert mit dem
Team-Zertifikat. Der Tap lief mit `muteBehavior = .unmuted` — der Spike hört nur zu und
verändert die Wiedergabe nicht.
## Ergebnis: bestätigt
| Schritt | Ergebnis |
|---|---|
| `kAudioHardwarePropertyProcessObjectList` | 35 Audio-Prozesse, mit PID, Bundle-ID und `IsRunningOutput` |
| `AudioHardwareCreateProcessTap` | erzeugt, Tap-Objekt 195, Format 48 kHz / 2 Kanäle / 32 bit Float |
| `AudioHardwareCreateAggregateDevice` (privat) | erzeugt, Device 196 |
| IOProc über 5 s | 469 Callbacks (93/s), 480 256 Frames, Spitzenpegel 0,4516, **0 stille Puffer** |
480 256 Frames ÷ 2 Kanäle ÷ 5 s = 48 025 Hz — deckt sich exakt mit der Abtastrate.
93 Callbacks/s entsprechen ~512 Frames je Puffer, also rund 10,7 ms Latenz. Für einen
Lautstärkemixer unkritisch.
Der bekannte Fehlerfall „Puffer laufen nach einiger Zeit auf Null" trat im 5-Sekunden-Fenster
nicht auf. Das widerlegt ihn nicht — der Watchdog aus dem Plan bleibt.
**Kein TCC-Dialog erschienen.** Das ist noch kein Freibrief: `.unmuted` ist der harmlose Fall.
Ob `.mutedWhenTapped` — der Modus, den der Mixer wirklich braucht — eine Berechtigung anfordert,
muss in Phase 7 gegengeprüft werden.
## Fallstrick beim Bauen
Die verbreitete Kurzform für Core-Audio-Property-Arrays
```swift
var out = [T](repeating: unsafeBitCast(0 as Int, to: T.self), count: count)
```
**stürzt mit SIGTRAP ab**: `unsafeBitCast` verlangt identische Größen, `Int` hat 8 Byte,
`AudioObjectID` 4. Da stdout in eine Pipe voll gepuffert ist, kam nicht einmal die erste
Zeile Ausgabe — der Absturz sah aus wie ein stiller Fehlstart. `MetricsProvider` und
`AudioProvider` verwenden deshalb die Variante über `UnsafeMutableRawPointer.allocate`
mit `MemoryLayout<T>.stride`.
## Konflikt: SoundSource ist bereits installiert
Die Prozessliste zeigt `com.rogueamoeba.soundsource` und `com.rogueamoeba.arkaudiod`,
dazu liegt `/Library/Audio/Plug-Ins/HAL/ARK.driver` im System. **SoundSource 6.1.1** ist
Rogue Amoebas Per-App-Lautstärkemixer — funktional genau das, was Phase 7 bauen soll.
Zwei Folgen:
1. **Doppelung.** Der Mixer aus Phase 7 wäre ein Nachbau einer bereits vorhandenen,
ausgereiften und bezahlten App.
2. **Technisches Risiko.** ARK.driver ist ein HAL-Plugin, das Audio abfängt, bevor es das
Ausgabegerät erreicht. Leitet SoundSource einen Prozess bereits um, sieht ein
Onyx-Process-Tap auf denselben Prozess unter Umständen nichts mehr — oder beide Ebenen
schalten sich gegenseitig stumm. Sauber vorhersagen lässt sich das nicht; es müsste
gemessen werden.
Vor Phase 7 zu klären. Bis dahin ist der Befund neutral festgehalten, nicht entschieden.
## Nebenbefund
`Alcove.app` und `Sapphire.app` sind beide installiert — also die beiden Apps, die als Vorbild
genannt wurden. Für die Notch-Shell in Phase 1 heißt das: mindestens eine davon greift
denselben Bildschirmbereich ab. Für die Entwicklung sollten sie beendet sein, sonst überlagern
sich Hover-Zonen und die Panels streiten sich um dieselbe Fläche.

45
docs/spikes/D-appgroup.md Normal file
View File

@@ -0,0 +1,45 @@
# Spike D — App-Group-Container ohne Sandbox
Gemessen am 10.08.2026. Werkzeug: `Spikes/appgroup-probe.swift`,
gebaut und signiert über `Spikes/build-appgroup-probe.sh`.
## Ergebnis: bestätigt
Ein **nicht sandboxed** Prozess, signiert mit `Apple Development (PP34X97WS3)` und der
Entitlement `com.apple.security.application-groups`, erhält den Container:
```
/Users/scarriffle/Library/Group Containers/PP34X97WS3.group.com.scarriffleservices.calendarr
```
- `containerURL(forSecurityApplicationGroupIdentifier:)` liefert einen Pfad, nicht `nil`
- Schreibzugriff vorhanden
- `CFNotificationCenterAddObserver` auf dem Darwin-Center registriert ohne Fehler
Damit ist die Kernannahme des Plans belegt: Onyx kann nicht-sandboxed sein — und damit
IOKit, den privilegierten Helper und den MediaRemote-Adapter nutzen — **und** trotzdem den
Calendarr-Snapshot über `CalendarrCore` lesen. Die Bridge-App als Rückfalloption entfällt.
## Zustand des Containers
```
.com.apple.containermanagerd.metadata.plist
Library/
```
Kein `widget-cache.json`. Die Calendarr-Mac-App ist auf dieser Maschine noch nie gelaufen,
`SnapshotStore().read()` wird also korrekt `.neverWritten` liefern. Das ist der erste Zustand,
den das Kalender-Widget behandeln muss, und der einzige, der sich derzeit real testen lässt.
Anmerkung: das Verzeichnis existierte vor diesem Spike nicht — `containermanagerd` legt es beim
ersten Zugriff an. Ein vorhandener Container ist also **kein** Beleg dafür, dass Calendarr
jemals geschrieben hat. Genau deshalb ist `.neverWritten` ein eigener Zustand und nicht
„Datei fehlt".
## Offener Punkt
Der Spike signiert eine nackte Binary ohne Provisioning-Profil. Das App-Bundle bekommt von
Xcode ein eingebettetes Profil — das ist der weniger strenge Fall, der Zugriff kann dort nicht
schlechter sein. Beim ersten echten Build wird trotzdem gegengeprüft, dass `containerURL`
nicht `nil` ist; das ist laut Integrationsanleitung der Fehler, der sich sonst als leere App
ohne jede Meldung zeigt.