MaCocoa 071

Capitolo 071 - Plugin x Widget = Facile!

Qualche tempo fa mi sono trovato a realizzare un widget (per accedere ai feed RRS del mai abbastanza lodato sito Tevac). Non parlo qui del widget in sé, ma di come ha realizzato un plugin in Cocoa per effettuare alcune operazioni all'interno del widget stesso. La cosa si è rivelata piuttosto semplice, anche se piuttosto semplice era il compito da svolgere.

Sorgenti: esempi Apple e tanto Google.

Prima stesura: 25 dicembre 2005 (!!!)

Il widget ed il plugin

figura 01

figura 01

Non parlo del widget. Del resto, il widget stesso e tutto il suo codice javascript, html e css è accessibile direttamente non appena lo avete disponibile (è sufficiente utilizzare il menu contestuale Mostra Contenuto Pacchetto una volta selezionato il file che lo rappresenta). Basti dire che il widget piglia un feed rss (ovvero, un file xml), lo legge, ne ricava una serie di elementi formati da titolo, indirizzo ed eventualmente una descrizione più estesa, e li presenta all'interno di una coreografica finestra.

Le funzioni della prima versione del widget erano completamente realizzate tramite Javascript. Purtroppo, soffriva di un problema non indifferente per la lingua italiana: le lettere accentate non erano correttamente visualizzate. Piuttosto che stare lì a combattere con Javascript, mi son detto: perché non fare un plugin in Cocoa? Nel farlo, oltre a pasticciare con la rete, avrei anche imparato, appunto, come fare un plugin per un widget.

Quindi, in questo breve ed avulso capitolo di Macocoa, parlo di come realizzare un plugin per un widget. Il plugin è molto semplice: piglia un url, che dovrebbe rappresentare un file xml con all'interno un feed rss, e lo interpreta come una serie di elementi. Ciascun elemento è costituito da tre stringhe: un titolo, un link, ed una descrizione. Il plugin legge il file, interpreta le informazioni e le rende disponibili alla bisogna. Tutto qui.

Model

Quando si programma orientati agli oggetti, il paradigma MVC (Model, View, Controller) salta fuori quasi sempre. Anche nel caso di un plugin, abbiamo (benché sia assente la parte View, ovviamente a carico del widget) da considerare la parte di modellizzazione dei dati e la parte di controllo dell'interazione.

Comincio dalla parte di modellizzazione. È quasi giocoforza costruire una classe che rappresenti un elemento del feed rss:

@interface rssItem : NSObject {
    NSString    * theTitle ;
    NSString    * theLink ;
    NSString    * theDescr ;
}

- (id ) initWithTitle: (NSString*) theTit link: (NSString*) theLnk andDesc: (NSString*) theDesc ;

- (NSString *)theTitle;
- (void)setTheTitle:(NSString *)value;

- (NSString *)theDescr;
- (void)setTheDescr:(NSString *)value;

- (NSString *)theLink;
- (void)setTheLink:(NSString *)value;

Nulla di più semplice: un oggetto con tre variabili d'istanza, appunto titolo, link e descrizione, e metodi per inizializzarlo e accedere ai singoli elementi.

Non offenderò la vostra intelligenza esaminando nei dettagli i triviali metodi sopra indicati, ma parlo solo del metodo seguente (che funzionicchia, non lo nascondo):

NSMutableString *
adjustString ( NSString * src )
{
    NSMutableString    * t = [ NSMutableString stringWithString: src ];
    [t replaceOccurrencesOfString:@"&" withString:@"&" options:NSLiteralSearch range:NSMakeRange(0,[t length])];
    [t replaceOccurrencesOfString:@""" withString:@"\"" options:NSLiteralSearch range:NSMakeRange(0,[t length])];
    return ( t );
}

Ho introdotto questo metodo per trasformare alcune entità html che non ne volevano sapere. In realtà, ci sono altre entità che continuano a non volerne sapere (strani accenti e virgolette), ma non ho voglia di perderci troppo tempo.

Il passo successivo è la costruzione dell'insieme degli elementi a partire dallo url indicato. Mi serve un'altra classe, che è anche la classe principale di tutto il plugin, che svolge le funzioni restanti di modellizzazione, e le poche funzioni di controllo.

Controller

La classe TevacRssRead ha una sola variabile d'istanza:

@interface TevacRssRead : NSObject {
    NSMutableArray        * elementiRss ;
}

Si tratta di un array di oggetti rssItem, sopra introdotti. Per riempire questo array, ecco il metodo utilizzato. Assume come parametro lo url del file, ed esegue una serie di operazioni noiose che vado per ordine a commentare:

- (NSString *)
getRSSFeed: (NSString *)feedURL
{
    ...
    // trasformo la stringa in ingresso in un url
    theUrl = [ NSURL URLWithString: feedURL ];
    // prelevo il file dal server usando una codifica opportuna
    theStart = [ NSString    stringWithContentsOfURL: theUrl
                            encoding: NSISOLatin1StringEncoding
                            error: & theErr ];
    // controllo la presenza di errori attraverso il risultato
    if ( theStart == nil )
    {
        NSLog(@"TevacRssWidget plugin, getRSSFeed Error: %@", [theErr description] );
        return ( nil );
    }
    // trasformo la stringa in un documento XML
    // non ho fatto tutto direttamente perche' costruendo un XML
    // direttamente dallo URL, trasforma subito le lettere strane
    theXml = [ [ NSXMLDocument alloc] initWithXMLString: theStart
                    options: NSXMLDocumentTidyXML error: & theErr ];

La prima parte del codice si occupa di leggere il file XML. In realtà, le prime due istruzioni eseguono già il grosso del lavoro. Parto dalla NSString in ingresso come argomento del metodo, e la trasformo in un comodo url da utilizzare con le classi Cocoa. Lo NSUrl appena costruito è dato in pasto ad un potente metodo di NSString, che costruisce un oggetto con tutto il contenuto del documento indicato dallo url. A questo punto, la variabile theStart contiene già tutte le informazioni necessarie. Il primo passo è di trasformarlo in un oggetto NSXMLDocument. E qui, bisogna fare un momento di pausa, anzi due.

Primo punto: un oggetto della classe NSXMLDocument è di una comodità straordinaria quando si tratta di esaminare documenti in formato XML; questa classe nasce a partire da Mac Os X 10.4. Il fatto non è un problema in questa situazione, in quanto i widget stessi sono presenti a partire da questa versione del sistema operativo; ovviamente, se avete versioni più datate, questa classe non è utilizzabile.

Per quei pochi sprovveduti che non conoscono XML (me compreso), basti dire che i documenti XML sono file di testo che sono i cugini più scaltri e potenti dei file HTML. Un file XML serve a comunicare informazioni in maniera strutturata, e la struttura stessa del messaggio è descritta all'interno del file attraverso dei classici Tag. Un esempio è in genere più illuminante di qualsiasi spiegazione. Ecco come si presenta il feed di Tevac:

<?xml version="1.0" encoding="iso-8859-1"?>
<rss version="0.91">
    <channel>
        <title>Tevac RSS (solo titoli)</title>
        <link>
            http://www.tevac.com
        </link>
        <description>
            Open Source Mac Site
        </description>
        <language>
            en-gb
        </language>
        <item>
            <title>PowerMail 5.2.2</title>
            <link>
                http://www.tevac.com/article.php/2005122400425439
            </link>
        </item>
...
    </channel>
</rss>

Il file dichiara inizialmente che tipo di caratteri contiene e quale versione delle specifiche rss utilizza, poi presenta un canale di feed, del quale specifica nell'ordine il titolo, il link di riferimento ed una descrizione. Dichiara (sbagliando) in che lingua si sta ragionando, e poi passa ad elencare, item dopo item, le varie notizie presenti nel sito. In questo caso, le notizie sono presenti solo come titolo e link (altre versioni del file hanno anche una descrizione dell'articolo cui fanno riferimento). Maggiori informazioni presso la pagina di Tevac relativa.

Data la natura del file, la cosa più immediata per estrarre le informazioni è di cominciare, con pazienza, a lavorare con le stringhe ed estrarre i vari elementi delle informazioni. Ma perché lavorare quando se ne può fare a meno? La classe NSXMLDocument serve proprio a questo. Inizializzando tale classe con la stringa che rappresenta il contenuto del file, sarà la classe stessa ad eseminarne il contenuto, e costruire una struttura (necessariamente ad albero, per quelli tra voi che hanno una spruzzata di informatica nel proprio bagaglio culturale) di navigazione più adatta alla bisogna. Per vederla all'opera, basterà attendere qualche paragrafo.

Secondo punto: la classe NSXMLDocument presenta, tra i suoi metodi, il seguente:

- (id)initWithContentsOfURL:(NSURL *)url options:(unsigned int)mask error:(NSError **)error

A che pro, quindi, utilizzare una istruzione per recuperare il file indicato dallo url in una NSString, e poi utilizzare tale NSString per costruire lo NSXMLDocument? Non si poteva fare tutto in una unica istruzione?

Certo. Solo che, per ragioni che non mi sono chiare, il singolo passaggio perde le lettere accentate, che è proprio il problema che ha portato alla nascita del plugin. Detto in altre parole: se leggo il file rss con una unica istruzione, le lettere accentate sono rappresentate da caratteri strani e diventa un problema recuperale; se eseguo la lettura in una istruzione, e la trasformazione in xml in una seconda istruzione, le lettere accentate rimangono. Tutto ciò rimane per me molto misterioso, posso supporre che abbia a che fare con la codifica dei caratteri e i protocolli di trasferimento dati, ma, in base all'esperienza derivante da lunghi anni di battaglie informatiche (quando va che tanto basta, non toccare, ché si guasta), lascio le cose come stanno e non indago oltre.

Posso finalmente proseguire con un po' di controlli sulla natura del file. È bene verificare che il file letto sia un file rss valido, e che contenga un canale di notizie:

    // preparo una stringa per il risultato
    theRes = [ [ NSMutableString alloc ] init ] ;
    // estraggo l'elemento root
    aNode = [theXml rootElement] ;
    // deve essere un "rss"
    if ( [ [ aNode name ] compare: @"rss" ] != NSOrderedSame )
    {
        NSLog( @"TevacRssWidget plugin, not a valid feed (rss) -> %@", [ aNode name ]);
        return ( nil );
    }
    // il prossimo figlio dovrebbe essere un "channel"
    aNode = [aNode nextNode] ;
    if ( [ [ aNode name ] compare: @"channel" ] != NSOrderedSame )
    {
        NSLog( @"TevacRssWidget plugin, not a valid feed (chn) -> %@", [ aNode name ]);
        return ( nil );
    }
    // a questo punto sembra che abbiamo un feed ragionevole

Direi che ci siamo: buttiamo via gli eventuali elementi presenti in precedenza, e passiamo a leggere gli elementi di tipo item presenti all'interno del canale.

È da notare come si naviga all'interno di un file xml strutturato. Si parte da un elemento root, radice, e si salta di nodo in nodo con il metodo nextNode, oppure, se si tratta di nodi allo stesso livello, con il metodo nextSibling (dove ogni nodo è un pezzo di informazione racchiuso tra tag). Se un nodo è strutturato, si accede ai vari elementi contenuti nel suo interno attraverso il vettore dei figli (metodo children).

    // rilascio l'attuale collezione
    [ elementiRss removeAllObjects ];
    // esploro l'albero xml, entro nel livello interno
    aNode = [aNode nextNode] ;
    // esploro finche' ci sono nodi
    while ( aNode )
    {
        // mi interessano solo gli item
        if ( [ [ aNode name ] compare: @"item" ] == NSOrderedSame )
        {
            // se entro qui, ho trovato un item
            rssItem            * aNewItem ;
            NSString        * locTitle = nil ;
            NSString        * locLink = nil ;
            NSString        * locDesc = nil ;
            NSXMLNode        * figghiu ;            
            // esamino tutti i figli e metto da parte i valori
            NSEnumerator *en = [ [ aNode children ] objectEnumerator];
            while (figghiu = [en nextObject])
            {
                // mi interessano title, link e description
                if ( [ [ figghiu name ] compare: @"title" ] == NSOrderedSame )
                    locTitle = [ NSString stringWithString: [ figghiu stringValue ]] ;
                if ( [ [ figghiu name ] compare: @"link" ] == NSOrderedSame )
                    locLink = [ NSString stringWithString: [ figghiu stringValue ] ];
                if ( [ [ figghiu name ] compare: @"description" ] == NSOrderedSame )
                    locDesc = [ NSString stringWithString: [ figghiu stringValue ] ];
            }
            // ho un altro elemento da inserire
            rowCount += 1 ;
            aNewItem = [[ rssItem alloc] initWithTitle: locTitle link: locLink andDesc:locDesc ];
            [ elementiRss addObject: aNewItem ];
        }
        // passo al nodo successivo
        aNode = [ aNode nextSibling ];
    }
    // restituisco il numero di elementi presenti
    return ( [ NSString stringWithFormat: @"%d", rowCount] );
}

Per ogni elemento riconosciuto come item, provo a vedere se dispone di un titolo, di un link e di una descrizione. Con questi tre oggetti stringa poi costruisco un oggetto rssItem, che vado ad aggiungere alla mia collezione. Al termine delle operazioni, restituisco (sotto forma di stringa, piuttosto che di numero puro, che è difficile da trasferire indietro al widget) il numero di elementi presenti.

Il plugin

In effetti, nulla di quanto visto finora ha un legame diretto con il fatto di stare in un plugin per widget. Nulla impedisce di utilizzare il metodo precedente all'interno di una applicazione generica per leggere un feed rss generico (anzi, all'inizio ho fatto proprio così, per sperimentare con url e xml). Adesso comincia la parte più specifica.

Parto, come al solito, dalla documentazione fornita da Apple. Questa dice che per fare un plugin occorre costruire un oggetto che risponda ad un po' di metodi (un protocollo!).

Il primo metodo è il seguente:

- (id) initWithWebView:(WebView*)webview

È chiamato all'inizializzione del widget (quando la view del view è inizializzata, verosimilmente), ed è utilizzabile per tutte le inizializzazioni del caso. Io ne approfitto per aggiustare il vettore degli elementi:

-(id)
initWithWebView: (WebView*)w
{
    // giusto per scrupolo
    self = [ super init ];
    if ( self )
    {
        elementiRss = [ NSMutableArray arrayWithCapacity: 0];
        [ elementiRss retain ];
    }
    return self;
}

Il secondo metodo da realizzare (sempre se si vuole che il plugin interagisca col widget, cosa che si suppone essere vera, altrimente serve a nulla) è:

- (void) windowScriptObjectAvailable:(WebScriptObject *)windowScriptObject

Con questo metodo, indico con quale nome è possibile chiamare il plugin attraverso istruzioni Javascript:

-(void)
windowScriptObjectAvailable:(WebScriptObject*)wso
{
    [ wso setValue:self forKey:@"TevacRSSPlugin"];
}

In altre parole, dall'interno del widget posso invocare i metodi e le variabili d'istanza delle classi presenti all'interno del plugin tramite il nome TevacRSSPlugin, come ad esempio (attenzione, il seguente è codice Javascript!!!):

    numofelem = TevacRSSPlugin.getRSSFeed( url );

Il passo successivo è la realizzazione del protocollo (informale: significa che non è proprio necessario realizzare tutti i metodi indicati, al limite la cosa non funziona) WebScripting. Il protocollo regolamenta come un plugin scritto in cocoa possa interagire con un linguaggio di scipting esterno.

Il primo metodo interessante nello scopo ma assolutamente noioso nella realizzazione mette a disposizione i metodi interni tramite nome:

+(NSString*)
webScriptNameForSelector:(SEL)aSel
{
    NSString    * selname = nil;
    if (aSel == @selector( getRSSFeed: ) )
    {
        selname = @"getRSSFeed";
    }
    else if (aSel == @selector( logMessage: ) )
    {
        selname = @"logMessage";
    }
    ...
    return selname;
}

Per ogni metodo presente nella classe, restituisco una stringa, che sarà quella da utilizzare all'interno di Javascript per chiamare il metodo corrispondente. Io mi sono limitato ad utilizzare gli stessi nomi.

I due metodi successivi regolano l'accesso ai metodi ed alle variabili d'istanza del plugin al resto del mondo:

+(BOOL)
isSelectorExcludedFromWebScript: (SEL)aSel
{    
    // i miei due metodi sono accettabili
    if ( aSel == @selector(getRSSFeed:)
        || aSel == @selector(logMessage:)
        ...
        )
    {
        return NO;
    }
    return YES;
}

+(BOOL)isKeyExcludedFromWebScript:(const char*)k
{
    return YES;
}

Con il primo metodo, indico esplicitamente (rispondendo NO alla domanda se escluderli o meno dall'interfaccia) quali sono i metodi del plugin che possono essere chiamati dall'esterno; ovviamente, i metodi permessi devono avere una corrispondenza con i nomi indicati dal metodo webScriptNameForSelector. Con il secondo metodo, rispondendo sistematicamente YES, dico che in pratica qualsiasi variabile d'istanza del plugin non è direttamente accessibile dall'esterno. Così facendo, mi sono risparmiato la scrittura del metodo

+ (NSString *)webScriptNameForKey:(const char *)name

che è l'equivalente di webScriptNameForSelector per le variabili d'istanza.

Per accedere alla variabili d'istanza, o meglio ai singoli elementi caricati dal feed rss, ho dei metodi espliciti che si basano sul numero d'ordine dell'elemento all'interno del vettore:

- (NSString *)
getTitleForItem: (NSString *) theIdx
{
    unsigned short    idx = [ theIdx intValue ];
    if ( [ elementiRss count ] < idx )
        return ( nil );
    return ( [ [ elementiRss objectAtIndex: idx] theTitle ] );
}

assieme ai molto simili getLinkForItem e getDescForItem.

Bene, i metodi fin qui visti sono tutti quelli necessari per partire. Ma solo adesso arriva la parte meno ovvia.

Il progetto XCode

figura 02

figura 02

Un plugin non è un'applicazione Cocoa, non è un tool per linea di comando, non è alcuna delle voci presenti nella finestra che compare quando si costruisce un nuovo progetto con XCode. Cosa fare allora?

Si deve selezionare la voce generica Cocoa Plugin, e modificare un po' di cose.

In primo luogo, si deve modificare l'estensione del file prodotto dalla compilazione: si cerca nelle informazioni relative al target la Wrapper Extension e la si pone a widgetplugin. Visto che si accede alla rete per recuperare file, occorre inserire all'interno del progetto anche il framework WebKit.framework, che trovate (con un po' di fatica: /System/Library/Frameworks/WebKit.framework) all'interno delle librerie di sistema. Poi, all'interno del file Info.plist, occorre aggiungere la voce:

    <key>NSPrincipalClass</key>
    <string>TevacRssRead</string>

figura 03

figura 03

per indicare quale sia la classe da prendere come riferimento per il plugin (che poi sarebbe la classe che realizza il protocollo WebScripting). Va da sé che il risultato della compilazione va inserito all'interno della cartella del widget, al livello principale dello stesso.

Ci sono anche due accorgimenti all'interno del widget, modificando il file Info.list del widget stesso (attenzione, non quello del plugin, quello del widget): notificare al widget l'esistenza del plugin

    <key>Plugin</key>
    <string>TevacRssPlugin.widgetplugin</string>

e poi (ma questo solo perché il widget accede alla rete; e comunque sarebbe necessario, nel caso del widget di Tevac, per poter accedere alla rete esterna al computer)

    <key>AllowNetworkAccess</key>
    <true/>

Senza questi quattro infimi ed indispensabili dettagli, non funziona alcunché.

Finalmente, è possibile utilizzare il plugin all'interno del widget. Ecco ad esempio come fare a leggere un file rss e costruire la struttura delle ultime news dal sito:

        if (TevacRSSPlugin)
        {
            var theFeed ;                            
            ...
            xx = feedData[selectedFeed] ;
            numofelem = TevacRSSPlugin.getRSSFeed( xx[0]);    
            var cnt = document.createElement ('div');
            for (var i = 0; i < numofelem ; ++i)
            {
                // prelevo l'elemento i-esimo
                var item1 = TevacRSSPlugin.getTitleForItem( i );
                var item2 = TevacRSSPlugin.getLinkForItem( i );
                var item3 = TevacRSSPlugin.getDescForItem( i );
                // costruisco una riga
                var row = createRow (item1, item2, item3, i );
                // la inserisco nel contenuto
                cnt.appendChild (row);
            }
            ...
        }
        else
        {
            DEBUG("Widget plugin not loaded.");
        }

Con tutto ciò, sono arrivato alla fine del plugin, dal comportamento soddisfacente per i miei miseri scopi, e spero adatto anche ai vostri (il widget è la prima opera con un mio contributo rilasciata al grande pubblico!!!).

Licenza Creative Commons
Eccetto dove diversamente specificato, i contenuti di questo sito sono rilasciati sotto Licenza Creative Commons.
Pagina a cura di Livio Sandel (macocoa2012@gmail.com).