iOS-fejlesztés másképp, avagy hogyan készítsünk cydiás tweakeket – 2. rész: „csak dinamikusan!”

Ez a cikk legalább 1 éve frissült utoljára. A benne szereplő információk a megjelenés idején pontosak voltak, de mára elavultak lehetnek.

Cydiás iOS-fejlesztéssel kapcsolatos sorozatunk e részében a rendszer és az alkalmazások módosításához elengedhetetlenül szükséges alapismereteket tesszük közzé.

project

Az Objective-C runtime library használata

Amint azt már az előző részben is említettem: az Apple nagy szívességet tett a jövőbeli cydiás fejlesztőknek (valószínűleg az első és utolsó szívesség, amit a fejlesztők az Apple-től kaptak…), amikor az Objective-C nyelvet választotta az alkalmazások „anyanyelvéül”. Ez a programozási nyelv ugyanis beépített támogatást nyújt az egyes osztályok és objektumok tulajdonságának futásidejű, „önreflexív” módosítására a runtime library, azaz a libobjc API-jainak segítségével.

Na de mire is jó mindez?

Először is, ha akár csak egy hivatalos App Store-ba készülő alkalmazást fejlesztünk, akkor is jól jöhet a futásidejű „introspekció”, ahogy az angolszász szakirodalom szereti emílteni ezt a funkcionalitást. Tegyük fel például, hogy alkalmazásunk egy JSON-alapú webservice-szel kommunikál, ami a különböző lekérdezésekre különböző típusú adatokkal válaszol (ez ténylegesen egy valós probléma). Tekintsük például a következő esetet: bizonyos esetekben (például: „Keressük a legfinomabb gyümölcsöt”) elég egyetlen objektum visszaadása:

{

    „fruit”: „apple”,

    „tastiest”: true,

    „origin”: „California”

}

Másokban viszont egy listával vagy tömbbel kell visszatérni (például: „Keressük a finom gyümölcsöket”), tehát valami ilyesmi:

{

    {

        „fruit”: „apple”,

        „tastiest”: true,

        „origin”: „California”

    },

    {

        „fruit”: „orange”,

        „tastiest”: false,

        „origin”: „Paris”

    }

]

A jól képzett iOS-fejlesztő ekkor gyorsan belenyúl virtuális zsebébe, előhúzza az NSJSONSerialization osztályt, és már kap is egy frissen sült narancsos-almás objektumot:

id <NSObject> obj = [NSJSONSerialization JSONObjectWithData:jsonData options:kNilOptions error:NULL];

Igen ám, csakhogy nem tudjuk konkrétan, hogy milyen objektumot kaptunk vissza: lehet az NSArray vagy NSDictionary, a választól függően. Ekkor nyúlhatunk az Objective-C runtime-hoz, és megkérdezhetjük az objektumtól, hogy milyen osztályba is tartozik:

if ([obj isKindOfClass:[NSArray class]]) {

    // egy egész raklapnyi gyümölcs (tömb)

} else if ([obj isKindOfClass:[NSDictionary class]]) {

    // egyetlen szem alma

} else {

    // romlott gyümölcs (ha egyik sem: hiba történt)

}

Ez már valóban egy olyan lehetőség, ami a dinamizmusnak köszönhető. Statikusan típusos programozási nyelvekkel (mint például a C) hasonló trükköt, tudniillik egy érték típusának a futásidejű meghatározását, nem lehet megvalósítani.

Egy másik fontos művelet lehet egy metódushoz tartozó függvény (ugye tudjátok, hogy minden Objective-C-metódus valójában egy sima C függényként kerül implementálásra?) lekérése, a metódusnév („selector”) alapján. Még itt sem igazán látszik, hogy az Objective-C runtime-mal dolgoznánk, hiszen, ahogyan az előző példában is, csupán az NSObject osztályt fogjuk használni. A magyarázat teljesen egyszerű: az NSObject sok olyan „beépített” metódust tartalmaz, amik csupán az Objective-C runtime körüli, kényelmi célokat szolgáló úgynevezett wrapperek. Álljon itt tehát, hogyan nyerhetjük ki egy selector segítségével egy adott metódus tényleges implementációját:

// Itt kapunk egy pointert az implementációra

IMP imp = [Osztaly instanceMethodForSelector:@selector(foo)];

Osztaly *obj = [[Osztaly alloc] init];

imp(obj, @selector(foo)); // itt pedig meghívjuk egy konkrét példányon

[obj release];

Az IMP egy „függvénymutató” (angolul jobban hangzik: function pointer) típus. Ha esetleg a C nyelvben való jártasságunk idáig már nem terjed, akkor gyorsan tanuljuk meg használni őket – a tweakfejlesztésben kikerülhetetlen szerepet játszanak.

Hogy végre a „csupasz” Objective-C runtime is látható legyen, íme az előzővel ekvivalens kód, közvetlenül a libobjc API felhasználásával:

Class oszt = objc_getClass(„Osztaly”);

// vagy ez:

Method met = class_getInstanceMethod(oszt, @selector(foo));

IMP imp = method_getImplementation(met);

// vagy rövidebben:

IMP imp = class_getMethodImplementation(oszt, @selector(foo));

// innentől kezdve pedig minden ugyanaz

Ha még ennél is tovább akarunk menni, akkor akár a felhasználótól is bekérhetünk egy tetszőleges stringet, és megpróbálhatjuk végrehajtani azt egy objektumon (a „textField” változó itt egy érvényes, inicializált UITextField példány kell, hogy legyen, amibe a felhasználó ír):

NSString *str = textField.text;

SEL sel = NSSelectorFromString(str); // selector (metódusnév) a felhaszánló által beírt szövegből

Osztaly *obj = [[Osztaly alloc] init];

if ([obj respondsToSelector:sel]) {

    [obj performSelector:sel];

}

[obj release];

Természetesen a valós életben, élesben ne tegyünk ilyet, mert ez komoly biztonsági kockázatot jelentene. Itt ez csak a teljesség és az érdekesség kedvéért áll.

Majdnem ugyanezt a csíntalanságot elkövethetjük az „instance variable” megnevezésű, példányokhoz kötött változók esetén is. Ez az én egyik személyes kedvencem is egyébként, már csak a már-már perverz módon kihasznált pointeraritmetika miatt is:

@interface Osztaly: NSObject {

    MasikOsztaly *peld_valtozo;

    CGRect valami_negyzet;

}

@end

// …

// Itt a függvényünk, ami a lényegi munkát végzi:

void *object_getIvarPointer(id obj, const char *name)

{

    Class cls = object_getClass(obj);

    Ivar ivar = class_getInstanceVariable(cls, name); // kiolvassuk az ivart reprezentáló Ivar struktúrát

    char *base_addr = (char *)obj;

    return base_addr + ivar_getOffset(ivar);

    // majd visszatérünk az objektum címéből és az Ivar struktúrából számolt abszolút címmel

}

// Itt pedig az, hogyan kell használni:

Osztaly *obj = [[Osztaly alloc] init];

void *ivarAddr;

MasikOsztaly *stolenIvar;

ivarAddr = object_getIvarPointer(obj, „peld_valtozo”

stolenIvar = *(MasikOsztaly **)ivarAddr;

CGRect rect;

ivarAddr = object_getIvarPointer(obj, „valami_negyzet”);

rect = *(CGRect *)ivarAddr;

Fontos megjegyezni, hogy ugyan az Objective-C runtime tartalmaz beépített függvényeket a „példányváltozók” olvasására és írására, ezek a föggvények mind a generikus „id” típussal dolgoznak, ami – mint tudjuk – egy pointertípus. Ez azt jelenti, hogy a pointernél nagyobb méretű értékek esetében (iOS-en ez a 32 bites architektúra miatt a 64 bites egészeket, a szintén 64 bites „double” típust, valamint természetesen a megfelelő méretű „struct” struktúrákat jelenti) nem használhatóak, míg a fenti függvény univerzális.

A következő lépésben még tovább megyünk típusok terén. Természetesen nem csak a változóknak, objektumoknak van típusa, hanem a metódusoknak és a függvényeknek is. Ezekről is kaphatunk információt a runtime segítségével (a releváns StackOverflow-kérdést és -választ is érdemes elolvasni):

Method m = class_getInstanceMethod([Osztaly class], @selector(foo:bar:));

char type[128];

method_getReturnType(m, type, sizeof(type));

Mi is történik itt tulajdonképpen? Az első sorban a metódust leíró Method struktúrát érjük el. A második sorban egy 128 karakternyi tömböt foglalunk le, majd a harmadik sor a Method struktúrából kinyeri a típusra vonatkozó információt.

(Fontos megjegyezni, hogy az eddigi néhány példában csak példánymetódusokra vonatkozó példákat hoztam, osztálymetódusokról nem volt szó. Ennek pusztán az az oka, hogy Objective-C-ben az osztálymetódusok is példánymetódusok, miután az osztályok is objektumok: a saját metaosztályuk példányai. Az „NSObject” osztály „+alloc” nevű metódusa tehát gyakorlatilag felfogható az „NSObject” metaosztály „-alloc” metódusaként is. Az összes fenti példában tehát az osztályokat a metaosztályukra kicserélve dolgozhatunk osztálymetódusokon is.

De mi is lesz mindezek után a „type” bufferben? Az Objective-C runtime egy, az összes (teljes) C és Objective-C típust reprezentálni képes típusleíró „nyelvet” is tartalmaz (teljes dokumentáció) (az „incomplete”, avagy „forward-declared” típusokra ez a típusleírás nem alklamazható). A lényeg, hogy minden egyes primitív típus egy-egy betűnek felel meg, az „összetett” típusok (pl. tömbök, struktúrák, objektumok) leírésa pedig ezek kombinációjával, néhány kiegészítő jelölés segítségével történik. Példák:

i → int

* → char *

f → float

: → SEL

@ → id

^{StructName=ci} → struct StructName { char ch; int i; } *

[128^i] → int[128]

Ennek a tudásnak a segítségével már továbbléphetünk egy még érdekesebb kérdéskörre: ne csak olvassuk a program adatait, hanem módosítsuk is azokat! Itt elsősorban az osztályok és metódusok viszonyáról van szó. Az egyik fontos manifesztációja a runtime ilyen célú felhasználásának az osztályokhoz való metódusok dinamikus hozzáadása. Miért lenne ilyesmire egyáltalán szükség, tehetjük föl a jogosnak látszó kérdést, mikor kategóriákat és „class extension”-öket is írhatunk? Nos, a válasz: a kategóriák és class extensionök készítése csak akkor lehetséges, ha az Objective-C compiler (pontosabban a linker) hozzáfér az ily módon kiterjeszteni kívánt osztály definíciójához (azaz vagy mi írtuk az eredeti osztályt is, és megvan a forráskódja, vagy egy library vagy framework exportálja, mint nyilvános, linkelhető szimbólumot). Ez pedig tweakek írása esetén általában nem igaz. Ha például egy olyan osztályt szeretnénk kiegészíteni, ami a SpringBoard részét képezi, akkor ahhoz a linker semmilyen formában nem tud hozzáférni. Így tehát nem marad más, mint a metódus teljesen dinamikus hozzáadása (ezt a trükköt kellett egyébként alkalmaznom a libipodimport szerveroldali részének megírásakor, pontosan ebből az okból kifolyólag):

const char *_SBUIController_$_foo_bar_(id self, SEL _cmd, id arg1, int arg2)

{

    NSString *str = [NSString stringWithFormat:@”%@ %d”, arg1, arg2];

    return strdup(str.UTF8String);

}

class_addMethod(

    objc_getClass(„SBUIController”),

    @selector(foo:bar:),

    (IMP)_SBUIController_$_foo_bar_,

    „*@:@i”

);

Magyarázat: a _SBUIController_$_foo_bar_ függvény adja a létrehozandó metódus implementációját, azaz a tényleges kódot, ami a metódushíváskor végrehajtódik. Ez a függvény pusztán annyit tesz, hogy megfelelően megformázva egy C stringben visszaadja két argumentumának emberi olvasásra is alkalmas leírását.

Az érdekes rész a „class_addMethod()” függvény hívása. Ennek az első argumentuma az osztály, amihez a metódust szeretnénk adni. Figyeljük meg, hogy itt nem használhatjuk az [SBUIController class] formát, hiszen az SBUIController osztály nem érhető el a linker számára, tekintve, hogy a SpringBoard egyik osztálya – a fordítás során tehát linker errort kapnánk (a híres-hírhedt „Undefined reference to symbol _$_OBJC_CLASS_$_SBUIController” hibaüzenetet).

A második argumentum egyszerűen a selector, a metódus neve, a harmadik pedig a függvénypointer, ami az implementációt tartalmazza. Az előző részben leírt típusleírásnak pedig a negyedik argumentum megírása során vesszük hasznát: a runtime itt a metódus típusát („function signature”) leíró sztringet vár. Ennek megkonstruálására a szabály: a legelső karakter a visszatérési értéket jelenti, a többi pedig az argumentumokét sorban. Ennek az is a következménye, hogy a class_addMethod() használata során a típussztring 2. és 3. karaktere mindig „@” és „:” lesz, hiszen ezt a két első implicit argumentumot minden Objective-C metódusként használt függvény megkapja.

A függvény meghívása után ugyanúgy használhatjuk a metódust, mintha azt kézzel implementálták volna:

char *str = [[SBUIController sharedInstance] foo:nil bar:1337];

A legutolsó műveletet nem véletlenül hagytam a sor végére. Ez a legösszetettebb dolog, amit praktikusan csinálni fogunk a runtime segítségével, ez igényli a legtöbb figyelmet, valamint ezt fogjuk a legtöbbször használni, habár nem is pontosan ilyen formában. Amiről most szó lesz, az metódusimplementációk cseréje. A szakirodalomban „hooking”, „method swizzling” és hasonló félelmetes neveken említik. A koncepció első ránézésre ennek ellenére meglehetősen egyszerű: vegyünk egy függvényt, és cseréljük ki erre a függvényre egy már meglévő metódus implementációját. Ez azt jelenti, hogy innentől az a metódus mást fog csinálni, amikor meghívják, mint amire eredetileg tervezték. Ugye ez már egészen hasonlít a tweakek működésére, nem igaz? 🙂

Csináljunk egy rossz heccet (persze csak gondolatban, vagy abban az esetben, ha ellopták a készülékünket): tegyük lehetetlenné a készüléken lévő alkalmazások megnyitását.

Nehezítés: valósítsuk meg mindezt kalapács és tömény salétromsav használata nélkül.

A nehezítés miatt a készülék fizikai tönkretételét jó közelítéssel kizártuk, marad tehát a bonyolultabb, ám elegánsabb megoldás: az ikonokat az Objective-C runtime segítségével rávesszük arra, hogy ne csináljanak semmit, ha megnyomják őket.

Ha közelebbről megnézzük a SpringBoard belső felépítését (azt, hogy ezt hogyan lehet viszonylag egyszerűen megtenni, a következő részben meg fogjátok tudni), találunk egy SBApplicationIcon nevű osztályt. Ez az osztály pontosan arra való, amire a neve utal: a SpringBoardon egy alkalmazás ikonját testesíti meg. Rendelkezik ez az osztály egy „-launch” nevű metódussal, ami szintén logikus nevet kapott: ez indítja az alkalmazást az ikonra való tapizáskor. Ha tehát ezt a metódust kicseréljük egy NOP-ra, akkor nem fognak indulni az alkalmazások. Első körben valami ilyesmiről lehet szó:

Class _$SBApplicationIcon = objc_getClass(„SBApplicationIcon”);

static IMP _original_$_SBApplicationIcon_$_launch;

void _modified_$_SBApplicationIcon_$_launch(id self, SEL _cmd)

{

    // nem csinálunk semmit

    // ha mégis meg akarnánk nyitni az alkalmazást,

    // akkor a régi, lecserélt implementációt meghívhatnánk:

    // _original_$_SBApplicationIcon_$_launch(self, _cmd);

}

Method m = class_getInstanceMethod(_$SBApplicationIcon, @selector(launch));

_original_$_SBApplicationIcon_$_launch = method_setImplementation(m, (IMP)_modified_$_SBApplicationIcon_$_launch);

Az első két sor ismét magáért beszél: a megfelelő osztályt kinyerjük a runtime segítségével, majd deklarálunk egy globális változót (ejnye, nem szép dolog…) az eredeti implementáció tárolására. A következő pár sor csak az üres függvény definiálására szolgál, majd a szokásos class_getInstanceMethod() után végül a lényeg: a method_setImplementation() függvény. Ez a függvény nem tesz mást, mint az első argumentumaként átadott metódus implementációját a második argumentumban átadott függvénypointerre változtatja, majd visszatér az eredeti implementációra mutató pointerrel, így az eredeti implementáció sem veszik el. Ezt meghívhatjuk (és általában meg is kell hívnunk) a módosított, „modified” függvényből.

A MobileSubstrate pontosan ezt a módszert alkalmazza, csak rengeteg, itt ki nem fejtendő biztonsági lépést tesz annak érdekében, hogy a tweakek ne „akadjanak össze” egymással és a rendszerrel,. Ez az, amiért általában érdemes a MobileSubstrate-hez fordulni az Objective-C runtime közvetlen meghívása helyett. Azt pedig, hogy pontosan hogyan használhatjuk a MobileSubstrate API-jait, megtudjátok két rész múlva, amikor is elkészítjük első, ténylegesen működő, komplett tweakünket!

Ezek még érdekelhetnek:


  1. Én mondjuk c#-ban szoktam programozni ( igaz nem ios-re) de ott is nagyon tetszett a reflexió, igencsak hasznos lehet néhány esetben, például amiket te is emlitesz a cikkben. Egyébként Gratula hozzá és kitartást, szerintem nagyon jó mindkét eddigi része.

Írd le a véleményedet! (Moderációs elveinket ide kattintva olvashatod.)

Hozzászólás írásához be kell jelentkezned!