chahinebrini 2cb1f8ad6e feat(binder-mac): SwiftUI Wizard für Self-Bind End-to-End-Flow
apps/rebreak-binder-mac/ — neue macOS-App die User durch den kompletten
Self-Bind-Prozess führt: Welcome → Preflight → Supervise → Enroll →
Configure (MDM-Push + Pre/Post-Check) → Sideload Lock-Profile (AirDrop).

3-Layer Smart-Resume: supervised? + Enrollment-Profil installed (cfgutil
Ground-Truth)? + MDM-Ack fresh (NanoMDM-DB via ssh+psql)?

Services: DeviceDetector (ideviceinfo + cfgutil), SuperviseRunner
(spawnt supervise-magic CLI), MDMClient (PUT /v1/enqueue?push=1, Apple
XML-Plist, identisch zum server-watcher-Format), MDMStatus (DB-Real-
Check + ManagedApplicationList-Result-Read).

Plus:
- fix(supervise-magic): EOF nach ProcessMessage Response (ErrorCode=0)
  ist Success, nicht Error — vermeidet false-fail bei iPhone-Restore-
  Reboot
- feat(mdm-profiles): rebreak-content-filter-mdm.mobileconfig als
  MDM-Push-Variante (ohne ConsentText, ohne globales allowAppRemoval=
  false — per-app via managed-state)

End-to-End validiert: App-Push via Ad-Hoc-Manifest (silent), Managed-
State via ManagedApplicationList-Query, NEFilter-Mode nach App-Force-
Quit, Lock-Profile non-removable nach Sideload.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 08:37:14 +02:00

362 lines
11 KiB
Go

// File-server-Loop für MobileBackup2 Restore-Mode.
//
// Während Restore drives das iPhone das gespräch:
// - Es schickt DLMessageDownloadFiles mit Filenamen
// - Es erwartet Datei-Content via custom binary chunks (NICHT DLMessage!)
//
// Wire-Format für File-Transfer (auf demselben Socket wie DLMessage):
//
// → [4-byte filename-length big-endian][filename UTF-8]
// → [4-byte code=12 DATA][4-byte 0 length] (start)
// → [4-byte code=12 DATA][4-byte chunk-length][chunk-data] (repeat)
// → [4-byte code=0 DONE][4-byte 0]
//
// Nach allen Files:
// → [4-byte 0 — no more files]
// → DLMessageStatusResponse [0, "___EmptyParameterString___", {}]
//
// Codes:
//
// CODE_FILE_DONE = 0 (end-of-file marker)
// CODE_FILE_DATA = 12 (data chunk)
// CODE_FILE_ERROR = 6 (error)
package mobilebackup2
import (
"encoding/binary"
"errors"
"fmt"
"io"
"github.com/raynis/rebreak-supervise-magic/internal/dlmessage"
)
// 1-byte chunk-codes (matched libimobiledevice's CODE_FILE_DATA etc).
const (
fileCodeDone byte = 0x00 // CODE_SUCCESS — end of file
fileCodeData byte = 0x0c // CODE_FILE_DATA — chunk
fileCodeError byte = 0x06 // CODE_ERROR_LOCAL — file not found / error
// Apple sentinel: status-response "no error".
emptyParameterString = "___EmptyParameterString___"
)
// FileProvider liefert Datei-Content für einen relativen Pfad.
// Returnt (content, true) wenn vorhanden, ([], false) sonst.
type FileProvider func(relpath string) ([]byte, bool)
// ServeFiles fängt das iPhone-driven file-serve-loop ab.
// Loopt bis DLMessageDisconnect oder Error.
//
// Files werden via `provider` geliefert. Wenn provider eine angefragte
// Datei nicht hat: Server returnt CODE_FILE_ERROR.
//
// onProgress wird bei jedem Mess-event aufgerufen (für UI/Log).
func (c *Client) ServeFiles(provider FileProvider, onProgress func(event string, info string)) error {
if onProgress == nil {
onProgress = func(string, string) {}
}
// Wenn iPhone uns ein ProcessMessage:Response mit ErrorCode=0 schickt,
// signalisiert das: Restore-Operation completed successfully. Danach
// trennt das iPhone die USB-Verbindung um in den Restore-Mode zu booten →
// wir bekommen ein EOF. Das ist KEIN Fehler — es ist erwartetes Verhalten.
successConfirmed := false
for {
t, args, err := c.dl.Receive()
if err != nil {
if successConfirmed {
// iPhone hat success-Response geschickt + disconnected um zu rebooten.
onProgress("ServeFiles", "device disconnected after successful Response — reboot expected")
return nil
}
return fmt.Errorf("serve: receive: %w", err)
}
switch t {
case dlmessage.TypeProcessMessage:
if len(args) == 0 {
continue
}
dict, ok := args[0].(map[string]interface{})
if !ok {
continue
}
msgName, _ := dict["MessageName"].(string)
// 2026-05-28 DEBUG: log full dict to understand what iPhone sends
onProgress("ProcessMessage", fmt.Sprintf("%s | full=%v", msgName, dict))
if msgName == "DLMessageDisconnect" || msgName == "Disconnect" {
return nil
}
// "Response" = iPhone signals operation completed — NICHT antworten,
// nur weiter loopen + auf nächste Message warten.
// Wenn ErrorCode=0 → success → nachfolgender EOF ist erwartet.
if msgName == "Response" {
if ec, ok := dict["ErrorCode"]; ok {
// ErrorCode kann uint64, int64, int etc. sein — alle als „0" prüfen
switch v := ec.(type) {
case uint64:
if v == 0 {
successConfirmed = true
}
case int64:
if v == 0 {
successConfirmed = true
}
case int:
if v == 0 {
successConfirmed = true
}
}
}
continue
}
// Andere Sub-Messages — Status OK respond.
if err := c.sendStatusOK(); err != nil {
return err
}
case dlmessage.TypeStatusResponse:
// Device-Status — wir loggen + machen weiter
onProgress("StatusResponse", fmt.Sprintf("%v", args))
// Falls error: abbrechen
if len(args) > 0 {
if code, ok := args[0].(uint64); ok && code != 0 {
return fmt.Errorf("serve: device error code=%d: %v", code, args)
}
}
case dlmessage.TypeGetFreeDiskSpace:
// Device fragt nach freiem Speicher — wir behaupten Schubladen voll Platz
onProgress("GetFreeDiskSpace", "")
// Antwort: [0, errStr, freeSpaceUint64]
if err := c.dl.Send(dlmessage.TypeStatusResponse, uint64(0), emptyParameterString, uint64(1<<40)); err != nil {
return err
}
case dlmessage.TypeContentsOfDirectory:
// Device möchte Verzeichnis listen — wir antworten mit empty dict
dirname := ""
if len(args) > 0 {
dirname, _ = args[0].(string)
}
onProgress("ContentsOfDirectory", dirname)
// Antwort: status [0, "", {}]
if err := c.dl.Send(dlmessage.TypeStatusResponse, uint64(0), emptyParameterString, map[string]interface{}{}); err != nil {
return err
}
case dlmessage.TypeCreateDirectory, dlmessage.TypeMoveItems, dlmessage.TypeRemoveItems, dlmessage.TypeCopyItem:
onProgress(t, "")
if err := c.sendStatusOK(); err != nil {
return err
}
case dlmessage.TypeDownloadFiles:
// Device fragt files an — args[0] ist []string
var fileList []string
if len(args) > 0 {
if arr, ok := args[0].([]interface{}); ok {
for _, item := range arr {
if s, ok := item.(string); ok {
fileList = append(fileList, s)
}
}
}
}
onProgress("DownloadFiles", fmt.Sprintf("count=%d", len(fileList)))
if err := c.sendFiles(fileList, provider, onProgress); err != nil {
return fmt.Errorf("serve: send files: %w", err)
}
case dlmessage.TypeUploadFiles:
// iPhone will UNS files uploaden (sein current state).
// Wir ACK + receive raw upload-stream.
onProgress("UploadFiles", "device uploading")
if err := c.sendStatusOK(); err != nil {
return err
}
if err := c.receiveFiles(onProgress); err != nil {
return fmt.Errorf("serve: receive files: %w", err)
}
case dlmessage.TypeDisconnect:
onProgress("Disconnect", "explicit")
return nil
default:
// unbekannter Typ — log + status OK
onProgress("Unknown", t)
if err := c.sendStatusOK(); err != nil {
return err
}
}
}
}
func (c *Client) sendStatusOK() error {
return c.dl.Send(dlmessage.TypeStatusResponse, uint64(0), emptyParameterString, map[string]interface{}{})
}
// sendFiles antwortet auf DLMessageDownloadFiles. Wire-format aus libimobiledevice
// mb2_handle_send_file verifiziert:
//
// per file:
// [4-byte BE filename-length][filename UTF-8]
// if file exists:
// per chunk: [4-byte BE (1+chunk-size)][1-byte 0x0c CODE_DATA][chunk-data]
// end-of-file: [4-byte BE 1][1-byte 0x00 CODE_SUCCESS]
// else:
// [4-byte BE 1][1-byte 0x06 CODE_ERROR_LOCAL]
//
// after all files:
// [4-byte BE 0] (terminator = "no more files")
// DLMessageStatusResponse [0, "___EmptyParameterString___", {<errors>}]
func (c *Client) sendFiles(fileList []string, provider FileProvider, onProgress func(string, string)) error {
for _, fname := range fileList {
content, ok := provider(fname)
onProgress("send-file", fmt.Sprintf("%s (%d bytes, found=%v)", fname, len(content), ok))
// filename header
if err := c.writeRawU32(uint32(len(fname))); err != nil {
return err
}
if err := c.deviceConn.Send([]byte(fname)); err != nil {
return fmt.Errorf("write filename: %w", err)
}
if !ok {
// ERROR: [length=1][CODE_ERROR_LOCAL]
if err := c.writeRawU32(1); err != nil {
return err
}
if err := c.deviceConn.Send([]byte{fileCodeError}); err != nil {
return err
}
continue
}
// DATA chunks
const maxChunk = 32 * 1024
for offset := 0; offset < len(content); offset += maxChunk {
end := offset + maxChunk
if end > len(content) {
end = len(content)
}
chunk := content[offset:end]
// [length=1+chunk][CODE_DATA][chunk]
if err := c.writeRawU32(uint32(1 + len(chunk))); err != nil {
return err
}
if err := c.deviceConn.Send([]byte{fileCodeData}); err != nil {
return err
}
if err := c.deviceConn.Send(chunk); err != nil {
return err
}
}
// END-OF-FILE: [length=1][CODE_SUCCESS]
if err := c.writeRawU32(1); err != nil {
return err
}
if err := c.deviceConn.Send([]byte{fileCodeDone}); err != nil {
return err
}
}
// Terminator: [length=0]
if err := c.writeRawU32(0); err != nil {
return err
}
// Final status
return c.sendStatusOK()
}
func (c *Client) writeRawU32(v uint32) error {
buf := make([]byte, 4)
binary.BigEndian.PutUint32(buf, v)
return c.deviceConn.Send(buf)
}
// receiveFiles empfängt iPhone's file-upload-stream nach DLMessageUploadFiles.
// Wire-format mirror von sendFiles (gleiche frames, andere Richtung):
//
// per file:
// [4-byte BE path-len][path]
// per chunk: [4-byte BE (1+chunk-size)][1-byte code][data]
// end-of-file: [4-byte BE 1][1-byte 0x00 SUCCESS]
// terminator: [4-byte BE 0]
//
// Wir discardieren den Inhalt — iPhone's "backup-of-backup" interessiert uns nicht,
// nur dass wir den Stream sauber durchlesen damit das Protokoll weitergeht.
func (c *Client) receiveFiles(onProgress func(string, string)) error {
reader := c.deviceConn.Reader()
totalFiles := 0
totalBytes := uint64(0)
for {
// path-length-prefix
var pathLenBuf [4]byte
if _, err := io.ReadFull(reader, pathLenBuf[:]); err != nil {
return fmt.Errorf("read path-len: %w", err)
}
pathLen := binary.BigEndian.Uint32(pathLenBuf[:])
if pathLen == 0 {
// terminator — no more files
onProgress("upload-done", fmt.Sprintf("%d files, %d bytes total", totalFiles, totalBytes))
return nil
}
if pathLen > 65536 {
return fmt.Errorf("upload-path too large: %d", pathLen)
}
// path-bytes
pathBuf := make([]byte, pathLen)
if _, err := io.ReadFull(reader, pathBuf); err != nil {
return fmt.Errorf("read path: %w", err)
}
path := string(pathBuf)
totalFiles++
// chunks
fileBytes := uint64(0)
for {
var chunkLenBuf [4]byte
if _, err := io.ReadFull(reader, chunkLenBuf[:]); err != nil {
return fmt.Errorf("read chunk-len for %s: %w", path, err)
}
chunkLen := binary.BigEndian.Uint32(chunkLenBuf[:])
if chunkLen == 0 {
// some implementations send zero-length as terminator
break
}
// 1-byte code
var code [1]byte
if _, err := io.ReadFull(reader, code[:]); err != nil {
return fmt.Errorf("read chunk-code: %w", err)
}
dataLen := chunkLen - 1
if dataLen > 0 {
discard := make([]byte, dataLen)
if _, err := io.ReadFull(reader, discard); err != nil {
return fmt.Errorf("read chunk-data: %w", err)
}
fileBytes += uint64(dataLen)
}
// code 0x00 (CODE_SUCCESS) = end-of-file
if code[0] == fileCodeDone {
break
}
}
totalBytes += fileBytes
onProgress("upload-file", fmt.Sprintf("%s (%d bytes)", path, fileBytes))
}
}
// ErrServeAborted ist ein Sentinel den FileProvider zurückgeben kann um Loop
// zu beenden (z.B. bei Disconnect-Trigger).
var ErrServeAborted = errors.New("serve aborted")