- ChatBubble: useActionSheet replaces custom Modal (native iOS popup, Android bottom sheet) - DM mode (isDM prop): hides like-count, shows Insta-style heart badge under bubble when liked - Group chat unchanged - Cleanup: remove unused Modal/Platform imports, sheet styles, actionsOpen state - deploy.sh: auto-detect ANDROID_HOME + auto-create local.properties for local Gradle - NEXT_RELEASE.md: DM reactions release note - Includes other staged work across binder-mac, marketing, ops/mdm, ios/
252 lines
7.6 KiB
Go
252 lines
7.6 KiB
Go
// Manifest.db Runtime-Generator — baut die SQLite-DB die iOS während
|
|
// Restore liest um zu wissen welche Files im Backup liegen.
|
|
//
|
|
// Schema (Apple-public, verifiziert aus TechLockdown's extracted Manifest.db):
|
|
//
|
|
// CREATE TABLE Files (
|
|
// fileID TEXT PRIMARY KEY,
|
|
// domain TEXT,
|
|
// relativePath TEXT,
|
|
// flags INTEGER, -- 1=file, 2=directory
|
|
// file BLOB -- NSKeyedArchive-encoded MBFile metadata
|
|
// );
|
|
// CREATE TABLE Properties (
|
|
// key TEXT PRIMARY KEY,
|
|
// value BLOB
|
|
// );
|
|
//
|
|
// fileID-Berechnung: hex(SHA1(domain + "-" + relativePath))
|
|
package mobilebackup2
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha1"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"howett.net/plist"
|
|
|
|
_ "modernc.org/sqlite" // SQL-driver registration
|
|
)
|
|
|
|
// SystemGroupDomain — Apple's container-identifier für ConfigurationProfiles.
|
|
const SystemGroupDomain = "SysSharedContainerDomain-systemgroup.com.apple.configurationprofiles"
|
|
|
|
// File-entry für Manifest.db.
|
|
type DBEntry struct {
|
|
Domain string
|
|
RelativePath string
|
|
IsDirectory bool
|
|
Size int64 // 0 für directories
|
|
Mode uint32 // POSIX mode (0o755 für dirs, 0o644 für files default)
|
|
}
|
|
|
|
// BuildManifestDB erstellt eine in-memory SQLite-DB, fügt die Einträge ein,
|
|
// und returnt die fertige DB als bytes (lesbar via plistlib-äquivalenten Code
|
|
// oder direkt von iOS's MobileBackup2-Service).
|
|
func BuildManifestDB(entries []DBEntry) ([]byte, error) {
|
|
// SQLite-driver hat keinen In-Memory-to-bytes Pfad direkt — wir nutzen
|
|
// eine tmp-Datei, lesen sie nach dem Schreiben.
|
|
tmpFile, err := os.CreateTemp("", "manifest-*.db")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("manifest_db: tmpfile: %w", err)
|
|
}
|
|
tmpPath := tmpFile.Name()
|
|
tmpFile.Close()
|
|
defer os.Remove(tmpPath)
|
|
defer os.Remove(filepath.Join(filepath.Dir(tmpPath), filepath.Base(tmpPath)+"-shm"))
|
|
defer os.Remove(filepath.Join(filepath.Dir(tmpPath), filepath.Base(tmpPath)+"-wal"))
|
|
|
|
db, err := sql.Open("sqlite", tmpPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("manifest_db: open: %w", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// PRAGMA-Settings matched TL's extracted Manifest.db (2026-05-28 verifiziert):
|
|
// user_version=2 (app-defined backup-format magic — iOS may check this)
|
|
// auto_vacuum=2 (incremental — TL setzt das)
|
|
//
|
|
// journal_mode=wal NICHT gesetzt: WAL erzeugt -wal/-shm Files die wir nicht
|
|
// mit auf das iPhone schicken können. iPhone bekommt die DB self-contained.
|
|
pragmas := []string{
|
|
`PRAGMA auto_vacuum = 2`, // muss VOR CREATE TABLE gesetzt sein
|
|
`PRAGMA user_version = 2`,
|
|
}
|
|
for _, stmt := range pragmas {
|
|
if _, err := db.Exec(stmt); err != nil {
|
|
return nil, fmt.Errorf("manifest_db: pragma %q: %w", stmt, err)
|
|
}
|
|
}
|
|
|
|
// Schema
|
|
schema := []string{
|
|
`CREATE TABLE Files (
|
|
fileID TEXT PRIMARY KEY,
|
|
domain TEXT,
|
|
relativePath TEXT,
|
|
flags INTEGER,
|
|
file BLOB
|
|
)`,
|
|
`CREATE TABLE Properties (
|
|
key TEXT PRIMARY KEY,
|
|
value BLOB
|
|
)`,
|
|
}
|
|
for _, stmt := range schema {
|
|
if _, err := db.Exec(stmt); err != nil {
|
|
return nil, fmt.Errorf("manifest_db: schema: %w", err)
|
|
}
|
|
}
|
|
|
|
// Insert entries
|
|
insert, err := db.Prepare(`INSERT INTO Files (fileID, domain, relativePath, flags, file) VALUES (?, ?, ?, ?, ?)`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("manifest_db: prepare: %w", err)
|
|
}
|
|
defer insert.Close()
|
|
|
|
for _, e := range entries {
|
|
fileID := ComputeFileID(e.Domain, e.RelativePath)
|
|
flags := 1
|
|
if e.IsDirectory {
|
|
flags = 2
|
|
}
|
|
mbfileBlob, err := EncodeMBFile(e.RelativePath, e.IsDirectory, e.Size, e.Mode)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("manifest_db: MBFile %s: %w", e.RelativePath, err)
|
|
}
|
|
if _, err := insert.Exec(fileID, e.Domain, e.RelativePath, flags, mbfileBlob); err != nil {
|
|
return nil, fmt.Errorf("manifest_db: insert %s: %w", e.RelativePath, err)
|
|
}
|
|
}
|
|
|
|
// Close + flush
|
|
if err := db.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Read file back as bytes
|
|
data, err := os.ReadFile(tmpPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("manifest_db: read tmp: %w", err)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// ComputeFileID — Apple's fileID-Berechnung: SHA1(domain + "-" + relativePath).
|
|
//
|
|
// Empirisch verifiziert mit TL's extracted Manifest.db:
|
|
//
|
|
// domain="SysSharedContainerDomain-systemgroup.com.apple.configurationprofiles"
|
|
// relativePath=""
|
|
// → fileID="9581eb754ee03b7f535293caf770235f0f37f8a8"
|
|
func ComputeFileID(domain, relativePath string) string {
|
|
h := sha1.New()
|
|
h.Write([]byte(domain + "-" + relativePath))
|
|
return hex.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
// EncodeMBFile — empirisch verifiziert gegen TL's bplist_02 (CloudConfigurationDetails-Entry):
|
|
//
|
|
// LastModified: 1554229750 (2019-04-02)
|
|
// LastStatusChange: 1554229750
|
|
// Birth: 1554229750
|
|
// InodeNumber: 51802
|
|
// UserID: 501
|
|
// GroupID: -2
|
|
// Flags: 0
|
|
// Mode: 33188 (0o100644 für regular file, 0o40755 für dir)
|
|
// ProtectionClass: 4
|
|
// Size: <variable>
|
|
// RelativePath: UID(2)
|
|
// $class: UID(3)
|
|
//
|
|
// NOTE 2026-05-27: TL-Embed-Werte für DIR-MBFiles (Size=0, ProtClass=0/4,
|
|
// Mode=040000 für Library) wurden ausgetestet — iPhone bail't ohne Reboot.
|
|
// Schlussfolgerung: TL mutiert die Embed-Werte zur Runtime, der Extract ist
|
|
// nicht 1:1 verwendbar. Rolled back zur originalen "skip Size/ProtClass für
|
|
// dirs"-Logik die wenigstens Reboot in Restore-Mode triggerte.
|
|
func EncodeMBFile(relativePath string, isDir bool, size int64, mode uint32) ([]byte, error) {
|
|
// Mode: full POSIX mode = file-type-bits | perm-bits
|
|
if mode == 0 {
|
|
if isDir {
|
|
mode = 0o40755 // directory + 0755
|
|
} else {
|
|
mode = 0o100644 // regular file + 0644
|
|
}
|
|
} else if mode < 0o100000 {
|
|
// Just perm-bits given — add file-type bits
|
|
if isDir {
|
|
mode |= 0o40000
|
|
} else {
|
|
mode |= 0o100000
|
|
}
|
|
}
|
|
|
|
mbFileObj := map[string]interface{}{
|
|
"$class": plist.UID(3),
|
|
"LastModified": int64(1554229750), // TL's timestamp (2019-04-02)
|
|
"LastStatusChange": int64(1554229750),
|
|
"Birth": int64(1554229750),
|
|
"GroupID": int64(-2),
|
|
"UserID": int64(501),
|
|
"InodeNumber": int64(51802),
|
|
"Flags": int64(0),
|
|
"Mode": int64(mode),
|
|
"RelativePath": plist.UID(2),
|
|
}
|
|
if !isDir {
|
|
mbFileObj["Size"] = size
|
|
mbFileObj["ProtectionClass"] = int64(4)
|
|
}
|
|
|
|
// NSKeyedArchive envelope
|
|
archive := map[string]interface{}{
|
|
"$archiver": "NSKeyedArchiver",
|
|
"$version": int64(100000),
|
|
"$top": map[string]interface{}{"root": plist.UID(1)},
|
|
"$objects": []interface{}{
|
|
"$null", // 0
|
|
mbFileObj, // 1
|
|
relativePath, // 2
|
|
map[string]interface{}{ // 3
|
|
"$classname": "MBFile",
|
|
"$classes": []interface{}{"MBFile", "NSObject"},
|
|
},
|
|
},
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
enc := plist.NewEncoderForFormat(&buf, plist.BinaryFormat)
|
|
if err := enc.Encode(archive); err != nil {
|
|
return nil, fmt.Errorf("encode MBFile: %w", err)
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// DefaultRestoreEntries returnt die 4 Standard-Entries für Cloud-Config-
|
|
// Injection (matched TL's extracted Manifest.db):
|
|
//
|
|
// 1. root dir (relativePath="")
|
|
// 2. Library
|
|
// 3. Library/ConfigurationProfiles
|
|
// 4. Library/ConfigurationProfiles/CloudConfigurationDetails.plist (datei, size variable)
|
|
func DefaultRestoreEntries(cloudConfigSize int64) []DBEntry {
|
|
return []DBEntry{
|
|
{Domain: SystemGroupDomain, RelativePath: "", IsDirectory: true},
|
|
{Domain: SystemGroupDomain, RelativePath: "Library", IsDirectory: true},
|
|
{Domain: SystemGroupDomain, RelativePath: "Library/ConfigurationProfiles", IsDirectory: true},
|
|
{
|
|
Domain: SystemGroupDomain,
|
|
RelativePath: "Library/ConfigurationProfiles/CloudConfigurationDetails.plist",
|
|
IsDirectory: false,
|
|
Size: cloudConfigSize,
|
|
Mode: 0o644,
|
|
},
|
|
}
|
|
}
|