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:
60
Spikes/appgroup-probe.swift
Normal file
60
Spikes/appgroup-probe.swift
Normal 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
230
Spikes/audiotap-probe.swift
Normal 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
36
Spikes/build-appgroup-probe.sh
Executable 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"
|
||||
74
Spikes/build-mediaremote-adapter.sh
Executable file
74
Spikes/build-mediaremote-adapter.sh
Executable 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
62
Spikes/smc-diff.sh
Executable 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
333
Spikes/smc-dump.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user