From 41ee3cc1745244094d8ab80ca45ab52e6757ad48 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Mon, 10 Aug 2026 16:47:54 +0200 Subject: [PATCH] =?UTF-8?q?Phase=200:=20Repo-Ger=C3=BCst=20und=20vier=20Ve?= =?UTF-8?q?rifikations-Spikes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 10 + Spikes/appgroup-probe.swift | 60 +++++ Spikes/audiotap-probe.swift | 230 +++++++++++++++++++ Spikes/build-appgroup-probe.sh | 36 +++ Spikes/build-mediaremote-adapter.sh | 74 +++++++ Spikes/smc-diff.sh | 62 ++++++ Spikes/smc-dump.swift | 333 ++++++++++++++++++++++++++++ docs/spikes/A-smc.md | 90 ++++++++ docs/spikes/B-mediaremote.md | 81 +++++++ docs/spikes/C-audiotap.md | 64 ++++++ docs/spikes/D-appgroup.md | 45 ++++ 11 files changed, 1085 insertions(+) create mode 100644 .gitignore create mode 100644 Spikes/appgroup-probe.swift create mode 100644 Spikes/audiotap-probe.swift create mode 100755 Spikes/build-appgroup-probe.sh create mode 100755 Spikes/build-mediaremote-adapter.sh create mode 100755 Spikes/smc-diff.sh create mode 100644 Spikes/smc-dump.swift create mode 100644 docs/spikes/A-smc.md create mode 100644 docs/spikes/B-mediaremote.md create mode 100644 docs/spikes/C-audiotap.md create mode 100644 docs/spikes/D-appgroup.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9277ee3 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Spikes/appgroup-probe.swift b/Spikes/appgroup-probe.swift new file mode 100644 index 0000000..84289bb --- /dev/null +++ b/Spikes/appgroup-probe.swift @@ -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).") diff --git a/Spikes/audiotap-probe.swift b/Spikes/audiotap-probe.swift new file mode 100644 index 0000000..a42d5cb --- /dev/null +++ b/Spikes/audiotap-probe.swift @@ -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(_ 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.stride + let raw = UnsafeMutableRawPointer.allocate(byteCount: Int(size), + alignment: MemoryLayout.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(_ object: AudioObjectID, _ addr: AudioObjectPropertyAddress, _ initial: T) -> T? { + var addr = addr + var value = initial + var size = UInt32(MemoryLayout.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.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.size + frameCount += count + let samples = data.assumingMemoryBound(to: Float.self) + for i in 0.. 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.") +} diff --git a/Spikes/build-appgroup-probe.sh b/Spikes/build-appgroup-probe.sh new file mode 100755 index 0000000..122fad7 --- /dev/null +++ b/Spikes/build-appgroup-probe.sh @@ -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' + + + + + com.apple.security.application-groups + + PP34X97WS3.group.com.scarriffleservices.calendarr + + + +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" diff --git a/Spikes/build-mediaremote-adapter.sh b/Spikes/build-mediaremote-adapter.sh new file mode 100755 index 0000000..eaacb21 --- /dev/null +++ b/Spikes/build-mediaremote-adapter.sh @@ -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" < + + + + CFBundleExecutable$NAME + CFBundleIdentifiercom.vandenbe.$NAME + CFBundleName$NAME + CFBundlePackageTypeFMWK + CFBundleShortVersionString0.1 + CFBundleVersion0.1.0 + + +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" diff --git a/Spikes/smc-diff.sh b/Spikes/smc-diff.sh new file mode 100755 index 0000000..3b9c387 --- /dev/null +++ b/Spikes/smc-diff.sh @@ -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 diff --git a/Spikes/smc-dump.swift b/Spikes/smc-dump.swift new file mode 100644 index 0000000..839810a --- /dev/null +++ b/Spikes/smc-dump.swift @@ -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.stride + + let result = IOConnectCallStructMethod(connection, kSMCHandleYPCEvent, + &input, MemoryLayout.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.. 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.. 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. diff --git a/docs/spikes/C-audiotap.md b/docs/spikes/C-audiotap.md new file mode 100644 index 0000000..be45ee6 --- /dev/null +++ b/docs/spikes/C-audiotap.md @@ -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.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. diff --git a/docs/spikes/D-appgroup.md b/docs/spikes/D-appgroup.md new file mode 100644 index 0000000..1323105 --- /dev/null +++ b/docs/spikes/D-appgroup.md @@ -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.