Angular And Chrome Extension

1. Začíname

1.1. Vytvorenie Angular projektu

  • ng new angularChromeTutorial

1.2. Vytvorenie ChromeExtension v Angular projekte

  • vytvoríme nový súbor src/assets/background.js
    • zabezpečíme tak otvorenie angular projektu po kliku na ikonku rozšírenia

chrome.browserAction.onClicked.addListener(function() {
	chrome.windows.create({
		'url' : 'index.html',
		'type' : 'popup',
		'height' : 800,
		'width' : 500,
		'left' : 1
	}, function(window) {
	});
});

  • vytvoríme nový súbor src/assets/bg.html, ktorý bude background skript spúšťať - túto stránku nikdy nevidno.

<!doctype html>
<html>
  <head>
    <title>Background Page--invisible</title>
    <script src="background.js"></script> 
  </head>
  <body>
  </body>
</html>

  • vytvoríme nový súbor src/mainfest.json

{
  "name": "Angular a ChromeExtension - tutoriál",
  "version": "1.0",
  "description": "Ukážka Angularu v Chrome extension",
  "manifest_version": 2,
  "permissions": ["storage", "tabs"],
  "background": {
    "page": "assets/bg.html"
  },
  "browser_action": {
    "default_title": "Angular a ChromeExtension - tutoriál"
  },
  "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'"
}

  • v súbore angular.json rozšírime assets pole o "src/manifest.json"

1.3. Vybuildovanie a zverejnenie v Chrome

  • npm audit fix --force
  • ng build
  • v chrome ďalšie nástroje> rozšírenia > načítať rozbalené
    • musí byť nastavený režim pre vývojárov

2. Prepojenie Angularu s content scriptom

2.1. Content script

  • Vytvoríme súbor src/assets/contentScript.js - príkaz v ňom odchytáva všetky správy z rozšírenia a ak príde požiadavka 'getCurrentUrl' posiela aktuálu URL adresu

console.log('content script sa spustil');

chrome.runtime.onMessage.addListener(function(message, sender, sendResponse) {
    if (message.type == 'getCurrentUrl') {
      console.log('content script posiela url ' + window.location.href);
    	sendResponse(window.location.href);
    }
});

  • aby bol content script pridaný na každú stránku v prehliadači dodáme do manifest.json na koniec pred poslednú zátvorku:

  "content_scripts": [
    {
      "matches": [ "http://*/*", "https://*/*", "file:///*/*" ],
      "js": [ "assets/contentScript.js"],
      "run_at": "document_end"
    }
  ]

2.2. Rozšírime funkcionalitu background.js

  • V background.js si chceme pred otvorením okna angularu pamätať tabku, ktorá bola aktívna, keď sa kliklo na ikonku rozšírenia. Tiež sa prichystáme na prijatie žiadosti z Angularu o získanie štartovacej tabky, aby potom mohol kontaktovať už priamo contentScript.js a nie background.js. Dopíšeme:

var startTabId; //tabka pri ktorej bolo extension otvorene
chrome.browserAction.onClicked.addListener(function() {
	chrome.tabs.query({active: true, currentWindow: true}, function(arrayOfTabs) {
		startTabId = arrayOfTabs[0].id; 
	});
}

chrome.runtime.onMessage.addListener(function(message, sender, sendResponse) {
	if ( message.type == 'getStartTabId' ){ // message from Angular - sending id of tab
		sendResponse({startTabId: startTabId});
	}
});

2.3. content-script.service.ts

  • na komunikáciu s content scriptom si vytvoríme service v Angulari:
    • ng g service content-script
  • aby sme mohli skompilovať typescript, ktorý pracuje s premennou chrome, musíme dodať do projektu definičný súbor /src/app/chrome/index.d.ts z adresy https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/chrome/index.d.ts
  • editujeme content-script.service.ts
    • importujeme Observable a Subscriber z rxjs balíka
    • vytvoríme si metódu, ktorá vráti Observable objekt informujúci o zmenách URL adresy a vytvoríme si inštančnú premennú urlSubscriber, cez ktorú budeme posielať nové URL adresy tomu, kto tento Observable objekt sleduje.
    • aby sme hneď aj zistili URL adresu tabky, pri ktorej sme pustili Angular, naprv si popýtame od background scriptu ID štartovacej tabky a potom sa content scriptu v tejto tabke spýtame na jej URL. Keď nám ju povie pošleme ju do urlSubsriber-a.

import { Injectable } from '@angular/core';
import { Observable, Subscriber } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class ContentScriptService {
  private selectedTabId = 0;
  private urlSubscriber:Subscriber<string>;

  constructor() { 
  }

  public urlObservable(): Observable<string> {
    return new Observable<string>(observer => {
      this.urlSubscriber = observer;

      chrome.runtime.sendMessage({type: 'getStartTabId'}, response => {
        this.selectedTabId = response.startTabId;
        this.askUrl();
      });
    });
  }

  private askUrl() {
    chrome.tabs.sendMessage(this.selectedTabId, {type: 'getCurrentUrl'}, url =>{
      this.urlSubscriber.next(url);
    });
  }
}

2.4. komponent

  • Posledná vec, čo spravíme, je zobrazenie URL v komponente. Musíme si nechať inject-núť ContentScriptService a zaregistrovať si poslucháča na Observable objekt vrátený z metódy urlObservable() a pri každej udalosti zmeny zmeniť inštančnú premennú currentUrl, ktorú zobrazujeme používateľovi cez šablónu komponentu. NgZone sa použije preto, aby sa vyrenderovala stánka vždy, keď zmeníme premennú currentUrl.
  • app.component.ts:

import { Component, OnInit, NgZone } from '@angular/core';
import { ContentScriptService } from './content-script.service';


@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
  private currentUrl:string;

  constructor(private contentScriptService: ContentScriptService, private zone:NgZone) {}

  ngOnInit() {
    this.contentScriptService.urlObservable().subscribe(
        url => { 
          this.zone.run(() => { 
            this.currentUrl = url;
          });
        });  
  }
}

3. Zmena currentUrl, keď klikáme hore dole v Chrome

3.1. Nové tabky

  • Rozšírime contentScript.js: Oznámime každé spustenie content scriptu - je to informácia o tom, že sa zmenila stránka v tabke

chrome.runtime.sendMessage({type : 'newTabContent'});

  • Rozšírime ContentScriptService
    • prichystáme si metódu messageRecieved na prijímanie všetkých správ z chrome v konštruktore a zachytíme v nej správu z novospusteného contentScriptu - keď otvoríme novú tabku
    • overíme si, či už máme inštanciu urlSubscribera - teda či už sme v minulosti zistili URL pri prvom otvorení Angularu a teda ide o novú URL

constructor() { 
    chrome.runtime.onMessage.addListener((message, sender, sendResponse) => this.messageRecieved(message, sender));
  }

  private messageRecieved(message, sender) {
    if (message.type === 'newTabContent') { // sprava z content scriptu
      console.log('zmena obsahu stránky');
      this.selectedTabId = sender.tab.id;
      if (this.urlSubscriber) {
        this.askUrl();
      }
    }
  }

3.2. Prepínanie medzi tabkami

  • Ak sa prepneme do inej tabky, background script to vie odchytiť a poslať správu Angularu:

chrome.tabs.onActivated.addListener(function (activeInfo) { //Fired when an active tab is changed
	if (activeInfo.windowId === this.selectedWindowId) {
		chrome.tabs.get(activeInfo.tabId, tab => {
			chrome.runtime.sendMessage({type: 'activeTabChanged', tabId: activeInfo.tabId, url : tab.url});
		});
	}
});

  • V contentScriptService.ts dodáme do messageRecieved ďalší if:

    if (message.type === 'activeTabChanged') { // sprava z background scriptu
      console.log('zmena aktívnej tabky');
      this.selectedTabId = message.tabId;
      this.urlSubscriber.next(message.url);
    }

4. Skývanie elementov

4.1. contentScript.js

  • Na zapamätanie si pozície elementov, ktoré chceme skryť budeme používať jazyk XPath - metóda getPathTo
  • Napíšeme si metódu hideAndReturnXPath, ktorá vezme kliknutý element, zistí si jeho XPath a pošle ho Angularu ako správu 'xPathOfClickedElement'
  • Túto metódu zaregistrujeme ako obsluhu udalosti na stlačenie myši a zakážeme jej propagáciu pre ďalšie listenery a zakážeme predvolené správanie - napr. nebudeme nasledovať linky - chceme len odchytiť pozíciu, nie aj naozaj realizovať bežnú akciu po kliku
  • Túto registráciu spravíme iba vtedy, keď nám angular pošle správu 'hideAndSendXPath' , a parametrom once povieme, že po použítí sa má odregistrovať - stránka zasa funguje ako bez nášho rozšírenia.

chrome.runtime.onMessage.addListener(function(message, sender, sendResponse) {
//... predchádzajúce if-y
    if (message.type == 'hideAndSendXPath') {
        window.addEventListener("mousedown", hideAndReturnXPath, {capture : true, once: true});
        window.addEventListener("click", (e) => {
            event.stopImmediatePropagation();
            event.stopPropagation();
            event.preventDefault();        
        }, {capture : true, once: true});
    }
});
var hideAndReturnXPath = function(event) {
    var el = event.srcElement;
    el.style.visibility = "hidden";
    var path= getPathTo(el);
    event.stopImmediatePropagation();
    event.stopPropagation();
    event.preventDefault();
    chrome.runtime.sendMessage({type : 'xPathOfClickedElement', xPath : path});
}

// upravený zdroj: https://stackoverflow.com/questions/2631820/how-do-i-ensure-saved-click-coordinates-can-be-reloaed-to-the-same-place-even-i/2631931#2631931
function getPathTo(element) {
   if (element===document.body)
       return '//' + element.tagName;

   var ix= 0;
   var siblings= element.parentNode.childNodes;
   for (var i= 0; i<siblings.length; i++) {
       var sibling= siblings[i];
       if (sibling===element)
           return getPathTo(element.parentNode)+'/'+element.tagName+'['+(ix+1)+']';
       if (sibling.nodeType===1 && sibling.tagName===element.tagName)
           ix++;
   }
}

4.2. content-script.service.ts

  • vytvoríme metódu hideElementAndReturnXPath, ktorá vráti Observable pre XPath kliknutého elementu. V tejto metóde zapamätáme v inštančnej premennej xPathSubscriber, cez ktorú budeme posielať xPath, a samozrejme pošleme správu 'hideAndSendXPath' content scriptu
  • dodáme si aj nový if do messageRecieved(), ktorý bude príjimať xPath z content scriptu a pošle ho do xPathSubscribera. Keďže na každé volanie hideElementAndReturnXPath posielame iba jeden xPath, pošleme aj ukončenie prúdu hodnôt cez complete().

  private xPathSubscriber:Subscriber<string>;

  private messageRecieved(message, sender) {
// ostatné if-y
    if (message.type === 'xPathOfClickedElement') { // sprava z content scriptu
      console.log('došiel xPath kliknutého elementu');
      if (this.xPathSubscriber) {
        this.xPathSubscriber.next(message.xPath);
        this.xPathSubscriber.complete();
      }
    }
  }

  public hideElementAndReturnXPath(): Observable<string> {
    return new Observable<string>((observer) => {
      this.xPathSubscriber = observer;

      chrome.tabs.sendMessage(this.selectedTabId, {type: 'hideAndSendXPath'});
    });
  }

4.3. komponent - trieda

  • vytvoríme si metódu hideElement, ktorá zavolá našu metódu v contentScriptService a čaká na XPath. Keď ho dostane zapíše si ho do poľa vypnutých xPathov.
  private invisibleXPaths = new Array<string>();

  hideElement() {
    this.contentScriptService.hideElementAndReturnXPath().subscribe(xPath => {
      this.zone.run(() => { 
        this.invisibleXPaths.push(xPath); 
      });  
    });
  }

4.4. komponent - šablóna

  • vytvoríme si tlačidlo, ktoré iniciuje metódu hideElement() a vypíšeme aktívne vypnuté xPath-y

<button (click)="hideElement()">Hide element</button>
<p>Hidden XPaths:</p>
<div *ngFor="let item of invisibleXPaths">{{item}}</div>

5. Vypínanie a zapínanie skývania elementov

  • Ak zle klikneme, môže sa stať, že si schováme, čo nechceme. Preto je vhodné umožniť opätovné zobrazenie a schovávanie elementov. Tiež je vhodné zabezpečiť fungovanie skrývania, ak sa na stránku opätovne vrátim - teda pamätať si sadu XPathov pre každú URL zvlášť
  • Idea riešenia: pri zmene URL aplikujeme všetky zapnuté XPathy na danej stránke

5.1. komponent - trieda

  • vytvoríme si mapu urlToMap, ktorá si bude pre každú navštívenú URL pamätať mapu z XPathov do stavu použitia na schovanie elementu
  • v mape xPaths si budeme pamätať mapu z XPathov do použitia pre aktuálnu URL, aby sme ju mohli vypisovať
  • vytvoríme si metódu setVisibility, ktorá bude žiadať zmenu viditeľnosti podľa XPathu
  • vytvoríme si metódu applyVisibilityOnNewTab, ktorú budeme volať pri zmene URL, ktorá uloží do premennej xPaths akuálnu mapu a pre každý jej XPath nastaví viditeľnosť (jej volanie neuvádzam v kóde nižšie)

  private xPaths = new Map<string, boolean>();
  private urlToMap = new Map<string, Map<string, boolean>>();

  setVisibility(event, xPath) {
    console.log("menim visibiliy pre " + xPath + " na " + !event.target.checked);
    this.contentScriptService.setVisibility(xPath, !event.target.checked);
  }

  applyVisibilityOnNewTab(url:string) {
    if (!this.urlToMap.has(url)) {
      this.urlToMap.set(url, new Map<string, boolean>());
    }
    this.xPaths = this.urlToMap.get(url);
    this.xPaths.forEach((value: boolean, xPath: string) => {
      this.contentScriptService.setVisibility(xPath, !value);
    });
  }

5.2. komponent - šablóna

  • zobrazíme mapu ako zoznam checkboxov

<div *ngFor="let item of xPaths | keyvalue">
  <label><input type="checkbox" name="xpathchb" [checked]="item.value" (change)="setVisibility($event,item.key)"> {{item.key}}</label>
</div>

  • aby sme vedeli formuláre použiť, je potrebné pridať do app.module.ts

import { FormsModule } from '@angular/forms';
@NgModule({
  imports: [
  ...
    FormsModule
  ...
  ],
  ...
})


5.3. content-script.service.ts

  • dodáme metódu setVisibility

  public setVisibility(xP: string, visibility: boolean) {
    chrome.tabs.sendMessage(this.selectedTabId, {type: 'setVisibility', xPath : xP, visibility: visibility});
  }

5.4. contentScript.js

  • dodáme if pre správu 'setVisibility'

    if (message.type == 'setVisibility') {
        var el = document.evaluate(message.xPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
        if (message.visibility) {
            el.style.visibility = "visible";
        } else {
            el.style.visibility = "hidden";    
        }
    }

Celý projekt je k dispozícii na GitHube:

zdroje