webtrack-extension / src / background / Extension.js
Extension.js
Raw
import EventEmitter from 'eventemitter3';
import URLFilter from './core/URLFilter';

const EVENT_NAMES = {
  'event': 'onEvent',
  'focusTabCallback': 'onFocusTabCallback',
  'focusTab': 'onFocusTab',
  'extendPrivateMode': 'onExtendPrivateMode',
  'tabContent': 'onTabContent',
  'tabRemove': 'onTabRemove',
  'tab': 'onTab',
  'tabUpdate': 'onTabUpdate',
  'disconnectPopup': 'onDisconnectPopup',
  'connectedPopup': 'onConnectedPopup'
}

class Tab {

  constructor() {
    this.allow = true;
    this.disabled = false;
  }

  setState(name, boolean){
    this[name] = boolean;
  }

  getState(name){
    return this[name];
  }

}

/**
 * Chrome 91 BUG PATCH (2021.07.05): a bug that do not let reading the tabs
 * when they are being created, or when the focus changes. this function is a
 * wrapper with a tempoorized, 
 * Sources:
 * https://bugs.chromium.org/p/chromium/issues/detail?id=1213925
 * https://stackoverflow.com/questions/67806779/im-getting-an-error-tabs-cannot-be-edited-right-now-user-may-be-dragging-a-ta
 */
const ChromeWrapper = {
  chromeTabsQuery: function (params, callback) {
    chrome.tabs.query(params, tabs => {
      if (chrome.runtime.lastError) {
        setTimeout(function () {
          console.log(
            "##############################################################",
            "ChromeWrapper.chromeTabsQuery re-called (Patch for Chrome 91).",
            "##############################################################");
          ChromeWrapper.chromeTabsQuery(params, callback)
        }, 100); // arbitrary delay
      } else {
        callback(tabs)
      }
    })
  }
}



export default class Extension {

  /**
   * [constructor]
   * @param {Object}  urlFilter       [instance of URLFilter]
   * @param {Boolean} privateMode     [default: false]
   * @param {Boolean} changeIcon      [default: true]
   * @param {Array}   extensionfilter [default: []]
   */
  constructor(urlFilter, privateMode=false, changeIcon=true, extensionfilter=[]){
    this.urlFilter = urlFilter;
    this.changeIcon = changeIcon;
    this.tabs = {};
    this.privateMode = privateMode;
    this.extensionfilter = extensionfilter;
    this.activWindowId = 0;
    this.event = new EventEmitter();
    this.default_private_time_ms = 15*60*1000;

    this.prev_active_tab = -1;
    this.active_tab = -1;

    this._onContentMessage = this._onContentMessage.bind(this);
    this._onTabUpdate = this._onTabUpdate.bind(this);
    this._onTabRemove = this._onTabRemove.bind(this);
    this._onActiveWindows = this._onActiveWindows.bind(this);
    this._onActivatedTab = this._onActivatedTab.bind(this);
    this._onTab = this._onTab.bind(this);
    this._onHighlightedWindows = this._onHighlightedWindows.bind(this);
    this._onDisconnectPopup = this._onDisconnectPopup.bind(this);
    this._onConnectPopup = this._onConnectPopup.bind(this);

    this.getAllTabsIds = this.getAllTabsIds.bind(this);
    this.pending_private_time_answer = false;

    this.debug = false;
  }

  /**
   * [_onActiveWindows listenen the active windowId for check the active tab]
   */
  _onActiveWindows(windowId){
    if (this.debug) console.log('-> _onActiveWindows');
    this.event.emit(EVENT_NAMES.focusTab, null, false);
    if(windowId>0) this.activWindowId = windowId;
  }


  /**
   * [_onConnectedPopup listen when the extension popup is open]
   */
  _onConnectPopup(externalPort){
    if (this.debug) console.log('-->_onConnectPopup:', externalPort);
    if (externalPort.name == "extension_popup"){
      externalPort.onDisconnect.addListener(this._onDisconnectPopup);
      this.event.emit(EVENT_NAMES.connectedPopup);
    }
    if (this.debug) console.log('<--_onConnectPopup:');
  }

  /**
   * [_onDisconnectPopup listen when the extension popup is closed]
   */
  _onDisconnectPopup(externalPort){
    if (this.debug) console.log('-->_onDisconnectPopup:', externalPort);
    if (externalPort.name == "extension_popup"){
      externalPort.onDisconnect.removeListener(this._onDisconnectPopup);
      this.event.emit(EVENT_NAMES.disconnectPopup);
    }
    if (this.debug) console.log('<--_onDisconnectPopup');
  }


  /**
   * _onHighlightedWindows listen when a tab is highlighed
   * @param  {[type]} highlightInfo [description]
   * @return {[type]}               [description]
   */
  _onHighlightedWindows(highlightInfo){
    if (this.debug) console.log('_onHighlightedWindows');
    this.event.emit(EVENT_NAMES.focusTab, null, false);
  }

  /**
   * [getAllTabsIds return all tabs]
   * @param  {Object}  query    [default: {}]
   * @param  {Boolean} onlyId   [default: true]
   * @return {Promise}
   */
  getAllTabsIds(query={}, onlyId=true){
    return new Promise((resolve, reject)=>{
      // Chrome 91 (Bug Fix): Using ChromeWrapper instead of
      // xbrowser.tabs.query
      ChromeWrapper.chromeTabsQuery(query, tabs => {
        if(onlyId){
          resolve(tabs.map(v => v.id))
        }else{
          resolve(tabs)
        }
      });
    });
  }

  /**
   * [setPrivateMode disable // enable privateMode]
   * @param {Boolean} b
   */
  setPrivateMode(b){
    if (this.privateMode != b) {
      this.notifyPrivateMode();
    }
    this.privateMode = b;
    this.resetPublicImage();
    //this.setImage(!this.privateMode);
  }


  /**
   * displayPrivateTimePopup send a message indicating that a popup should appear
   * @param {Boolean} 
   */
  async displayPrivateTimePopup(){
    this.pending_private_time_answer = true;

    // send a message
    // Chrome 91 (Bug Fix): Using ChromeWrapper instead of
    // xbrowser.tabs.query
    ChromeWrapper.chromeTabsQuery({active: true}, function(tabs){
      for (let tab of tabs) {
        try{
          xbrowser.tabs.sendMessage(tab.id, { 
              action: "popup_private_time", 
              display: true
            }, 
            function(response) {
              if(xbrowser.runtime.lastError) {
                if (this.debug) console.log('displayPrivateTimePopup: No front end tab is listening.');
              }
            }.bind(this));
        } catch (e){
          console.log('caught');
        }
      }
    }.bind(this));
  }


  /**
   * removePrivateTimePopup send a message indicating that the popup should be hidden
   * @param {Boolean} 
   */
  async removePrivateTimePopup(){
    this.pending_private_time_answer = false;
    // send a message to all tabs
    let tabs = await this.getAllTabsIds({}, false);
    if(tabs.length>0){
      for (let tab of tabs) {
        try{
          xbrowser.tabs.sendMessage(tab.id, {action: "popup_private_time", display: false}, 
            function(response) {
              if(xbrowser.runtime.lastError) {
                if (this.debug) console.log('removePrivateTimePopup: No front end tab is listening.');
              }
            }.bind(this));
        } catch (e){
          console.log('caught');
        }
      }
    }
  }


  /**
   * initFrontent send a message asking to initialize the frontend tracking
   * @param {Boolean} 
   */
  async initAllTabs(){
    if (this.debug) console.log('-> initAllTabs()');
    // send a message to all tabs
    let tabs = await this.getAllTabsIds({}, false);
    if(tabs.length>0){
      for (let tab of tabs) {
        try{
          xbrowser.tabs.sendMessage(
            tab.id, {
              action: "init"
            }, 
            function(response) {
              if(xbrowser.runtime.lastError) {
                if (this.debug) console.log('OnInit: No front end tab is listening.');
              }
            }.bind(this));
        } catch (e){
          console.log('caught');
        }
      }
    }
  }

  /**
   * removePrivateTimePopup send a message indicating that the popup should be hidden
   * @param {Boolean} 
   */
  async notifyPrivateMode(){
    // send a message to all tabs
    let tabs = await this.getAllTabsIds({}, false);
    if(tabs.length>0){
      for (let tab of tabs) {
        try{
          xbrowser.tabs.sendMessage(tab.id, {
            action: "private_mode", 
            private_mode: this.privateMode, 
            tab_disabled: this.tabs[tab.id].getState('disabled')
          }, 
            function(response) {
              if(xbrowser.runtime.lastError) {
                if (this.debug) console.log('notifyPrivateMode: No front end tab is listening.');
              }
            }.bind(this));
        } catch (e){
          console.log('caught');
        }
      }
    }
  }

  /**
   * [setImage set black or full color image]
   * @param {Boolean} b [default: false]
   */
  setImage(b=false){
    // console.log('before', b);
    // console.log(this.privateMode);
    if(this.privateMode) b = false;
    else if(!this.privateMode && !this.changeIcon) b = true;
    // console.log('after', b);
    xbrowser.browserAction.setIcon({path: b? 'images/on.png':  'images/off.png'});
  }


  /**
     * [resetPublicMode apropiately reset to public image]
     * @param {Boolean} b
     */
  async resetPublicImage(){   
    let activeTabIds = await this.getActiveTabIds();
    if(activeTabIds.length>0){
      for (let id of activeTabIds) {
        //this.resetImage(tabs[0].id);
        if (this.tabs.hasOwnProperty(id)) {

          // console.log('allow:', this.tabs[id].getState('allow'))
          // console.log('disabled:', this.tabs[id].getState('disabled'))
          // console.log('is_sm_path_allowed:', this.tabs[id].getState('is_sm_path_allowed'))
          // console.log('is_content_allowed:', this.tabs[id].getState('is_content_allowed'))
          // console.log('only_domain:', this.tabs[id].getState('only_domain'))
          // console.log('only_url:', this.tabs[id].getState('only_url'))
          // console.log('private_mode:', this.privateMode)

          this.setImage(this.tabs[id].getState('allow') 
              && !this.tabs[id].getState('disabled')
              && this.tabs[id].getState('is_sm_path_allowed')
              && this.tabs[id].getState('is_content_allowed')
              && !this.tabs[id].getState('only_domain')
              && !this.tabs[id].getState('only_url')
              && !this.privateMode);
          
        } else {
          this.setImage(this.privateMode);

        }

      }
    }
  }


  /**
   * [getActiveTabIds return list of active tabs]
   * @return {Promise} Array
   */
  getActiveTabIds(){
    return new Promise(async (resolve, reject)=>{
      try {
        let tabs = (await this.getAllTabsIds({}, false))
        if(this.activWindowId>=1) tabs = tabs.filter(e => e.windowId == this.activWindowId && e.highlighted == true);
        resolve(tabs.map(v => v.id));
      } catch (e) {
        reject(e)
      }
    });
  }

   /**
    * [_onActivatedTab
    *  run eventlistener EVENT_NAMES.tab if activated new Tab
    *  parameter:
    *   tabId: Number
    * ]
    */
  _onActivatedTab(activeInfo){
    //on switch the active tabs between one window
    if (this.debug) console.log('_onActivatedTab');

    this.prev_active_tab = this.active_tab;
    this.active_tab = activeInfo.tabId;
    
    if (this.pending_private_time_answer){
      this.displayPrivateTimePopup();
    }
    
    this.event.emit(EVENT_NAMES.focusTabCallback, activeInfo.tabId, false);
    if(!this.tabs.hasOwnProperty(activeInfo.tabId)){
      this.event.emit(EVENT_NAMES.focusTabCallback, null, false);
      this.setImage(false);
    }else{
      this.resetPublicImage();
    }
  }

  /**
   * [_onTab
   *  run eventlistener EVENT_NAMES.tab if create new Tab
   *  parameter:
   *   tabId: Number
   * ]
   */
  _onTab(tab){
    this.tabs[tab.id] = new Tab()
    this.event.emit(EVENT_NAMES.tab, tab.id, false);
  }

  /**
   * [_onTabRemove
   *  run eventlistener EVENT_NAMES.tabRemove for new content
   *  parameter:
   *   tabId: Number
   * ]
   */
  _onTabRemove(tabId){
    if(!this.privateMode && this.tabs.hasOwnProperty(tabId))
      this.event.emit(EVENT_NAMES.tabRemove, tabId, false);
  }

  /**
   * [_onTabUpdate
   *  run eventlistener EVENT_NAMES.tabUpdate for new content
   *  parameter:
   *   tabId: Number
   *   openerTabId: Number
   * ]
   */
  _onTabUpdate(tabId, info, tab){
    //if (this.debug) console.log('-> Extension._onTabUpdate');
    if(!this.privateMode && this.tabs.hasOwnProperty(tabId) && info.hasOwnProperty('status') 
      && info.status == 'complete' && tab.hasOwnProperty('title') && tab.hasOwnProperty('url')){
      if (this.debug) console.log('==== Emit Event: onTabUpdate ====');
      this.event.emit(EVENT_NAMES.tabUpdate, {
        tabId: tabId, 
        openerTabId: tab.hasOwnProperty('openerTabId')? tab.openerTabId: this.prev_active_tab, 
        tab: tab}, false);
    }//if
    //if (this.debug) console.log('<- Extension._onTabUpdate');
  }

  /**
   * [_onContentMessage
   *
   * run eventlistener EVENT_NAMES.tabContent for new content
   * parameter:
   *   tabId: Number
   *   url: String
   *   title: String
   *   html: String
   *   source: Array
   *   links: Array
   *   meta: String
   *   count: Number
   *
   * run eventlistener EVENT_NAMES.event for new event
   *   tabId: Number
   *   event: Array
   *  ]
   */
  async _onContentMessage(msg, sender, sendResponse){
      if (this.debug) console.log('-> _onContentMessage');

      if(msg==='ontracking'){
        if (this.debug) console.log('# ontracking');
        let domain = this.urlFilter.get_location(sender.tab.url).hostname;
        const url_rule = await this.urlFilter.is_allow(domain);
        this.tabs[sender.tab.id].setState('allow', url_rule != 'full_deny' || url_rule == 'full_allow');
        this.tabs[sender.tab.id].setState('webtrack_off', this.urlFilter.is_track_off(domain));
        this.tabs[sender.tab.id].setState('only_domain', url_rule == 'only_domain');
        this.tabs[sender.tab.id].setState('only_url', url_rule == 'only_url');

        //assume it is allowed
        this.tabs[sender.tab.id].setState('is_sm_path_allowed', true);
        this.tabs[sender.tab.id].setState('is_content_allowed', true);

        let r = {
          extensionfilter: this.extensionfilter, 
          pending_private_time_answer: this.pending_private_time_answer,
          default_private_time_ms: this.default_private_time_ms,
          privacy: {
              only_domain: this.tabs[sender.tab.id].getState('only_domain'),
              only_url: this.tabs[sender.tab.id].getState('only_url'),
              full_deny: !this.tabs[sender.tab.id].getState('allow'),
              webtrack_off: this.tabs[sender.tab.id].getState('webtrack_off'),              
              private_mode: this.privateMode,
              tab_disabled: this.tabs[sender.tab.id].getState('disabled')
          }
        }
        sendResponse(r);
      } else if (msg.hasOwnProperty('private_time')){
        if (this.debug) console.log('The user has requested more private time: ', msg.private_time);
        this.event.emit(EVENT_NAMES.extendPrivateMode, msg.private_time);
        this.removePrivateTimePopup();
        this.pending_private_time_answer = false;
        sendResponse(false);
      } else if(!this.tabs.hasOwnProperty(sender.tab.id) || 
          this.tabs[sender.tab.id].getState('disabled')){
        if (this.debug) console.log('# tab disabled');
        if (sender.tab.id == this.active_tab){
          this.setImage(false);
        }
        sendResponse(false);
      // background controls
      }else if(!this.tabs[sender.tab.id].getState('disabled') && this.tabs.hasOwnProperty(sender.tab.id)){
        if(typeof msg.content[0].html == 'boolean' && msg.content[0].html == false && sender.tab.id == this.active_tab){
          this.setImage(false);
          sendResponse(false);
        }else {        
          // if the property indicated that is allow to not track the content
          // then update the indicator, otherwise assume that it is allowed
          let is_sm_path_allowed = true;
          if (msg.content[0].hasOwnProperty('is_sm_path_allowed')){
            is_sm_path_allowed = msg.content[0].is_sm_path_allowed;
          }
          this.tabs[sender.tab.id].setState('is_sm_path_allowed', is_sm_path_allowed);

          let is_content_allowed = true;
          if (msg.content[0].hasOwnProperty('is_content_allowed')){
            is_content_allowed = msg.content[0].is_content_allowed;
          }
          this.tabs[sender.tab.id].setState('is_content_allowed', is_content_allowed);

          // update the indicator if this the active tab is the one sending the content
          // the DOM of a non active tab could be changing in the background
          if (sender.tab.id == this.active_tab){
            //this.setImage(is_sm_path_allowed && is_content_allowed);
            this.resetPublicImage();
          }
         
          // even if the content is blocked, the metainformation is sent in order to
          // keep track of the precursors
          msg = Object.assign(msg, {
            unhashed_url: msg.unhashed_url,
            title: sender.tab.title
          })
          msg.tabId = sender.tab.id;
          if (this.debug) console.log('==== Emit Event: onTabContent ====');
          this.event.emit(EVENT_NAMES.tabContent, msg, false);
          sendResponse(true);
          if (this.debug) console.log('==== Event emitted: onTabContent ====');

        }
        
        // return true;
      }else{
        debugger;
        if (this.debug) console.log('Private mode: ', this.privateMode);
        sendResponse(false);
      }
      
      if (this.debug) console.log('<- _onContentMessage');
      return true;
  }

  /**
   * [setTabPrivate set tab disabled]
   * @param {Boolean} boolean [default: false]
   * @param {Promise}
   */
  setTabPrivate(boolean=false){
    return new Promise(async (resolve, reject) =>{
        try {
          let tabId = (await this.getAllTabsIds({}, false)).filter(e => e.windowId == this.activWindowId && e.highlighted == true)[0].id;
          this.tabs[tabId].setState('disabled', boolean);
          if(boolean){
            this.event.emit(EVENT_NAMES.focusTabCallback, null, false);
            this.setImage(false);
          }
          resolve()
        } catch (e) {
          reject(e)
        }
    });
  }

  /**
   * [isTabPrivate return state disable of active tab]
   * @return {Promise} Boolean
   */
  isTabPrivate(){
    return new Promise(async (resolve, reject) =>{
        try {
          let tabId = (await this.getAllTabsIds({}, false)).filter(e => e.windowId == this.activWindowId && e.highlighted == true)[0].id;
          if (tabId && this.tabs[tabId]) {
            if (this.debug) console.log(tabId);
            resolve(this.tabs[tabId].getState('disabled'))
          }else{
            resolve(false);
          }
        } catch (e) {
          reject(e)
        }
    });
  }

  /**
   * [start load content]
   * @return {Promise}
   */
  start(){
    return new Promise((resolve, reject) => {
      xbrowser.tabs.onCreated.addListener(this._onTab);
      xbrowser.windows.onFocusChanged.addListener(this._onActiveWindows);
      xbrowser.tabs.onRemoved.addListener(this._onTabRemove);
      xbrowser.tabs.onUpdated.addListener(this._onTabUpdate);
      xbrowser.runtime.onMessage.addListener(this._onContentMessage);
      xbrowser.tabs.onActivated.addListener(this._onActivatedTab);
      xbrowser.runtime.onConnect.addListener(this._onConnectPopup);


      // function logURL(requestDetails) {
      //   console.log("Loading: " + requestDetails.url);
      // }
      // console.log('!!!!!!!!!!!!start!!');
      // xbrowser.webRequest.onBeforeRequest.addListener(logURL, {
      //   urls: ["https://api.twitter.com/*"]
      // });

      // function logResponse(responseDetails) { 
      //   console.log('responseDetails');
      //   console.log(responseDetails);
      // }
      // xbrowser.webRequest.onCompleted.addListener(
      //   logResponse,
      //   {urls: ["https://api.twitter.com/*"]}
      // );

      xbrowser.windows.getLastFocused({}, window => {
        if(window.id>0) this._onActiveWindows(window.id)
        // if(window.id>0) console.log('Change activWindowId %s', window.id);
      })
      xbrowser.tabs.onHighlighted.addListener(this._onHighlightedWindows);

      this.getAllTabsIds().then(tabIds => {
        for (let id of tabIds){
          this.tabs[id] = new Tab()
        }
      });

      resolve();
    });
  }

  /**
   * [remove all listener from eventemitter3 instance]
   */
  stop(){
    if (this.debug) console.log('-> Extension.stop()');

    this.tabs = {};
    xbrowser.tabs.onCreated.removeListener(this._onTab);
    xbrowser.windows.onFocusChanged.removeListener(this._onActiveWindows);
    xbrowser.tabs.onRemoved.removeListener(this._onTabRemove);
    xbrowser.tabs.onUpdated.removeListener(this._onTabUpdate);
    xbrowser.runtime.onMessage.removeListener(this._onContentMessage);
    xbrowser.tabs.onActivated.removeListener(this._onActivatedTab);
    // xbrowser.runtime.onConnect.removeListener(this._onConnectPopup);
    // xbrowser.runtime.onConnect.removeListener(this._onDisconnectPopup);
    // xbrowser.tabs.onHighlighted.removeListener(this._onHighlightedWindows);
    
    this.setImage(false);
    delete this
  }

  /**
   * [create browser notification]
   * @param  {String} [title='title']
   * @param  {String} [message='mycontent']
   * @param  {function} [onClose=()=>{}]
   */
  createNotification(title='title', message='mycontent', onClose=()=>{}){
    chrome.notifications.create(
      'name-for-notification',
      {
        type: 'basic',
        iconUrl: 'images/on.png',
        title: title,
        message: message
      },
      onClose
    )
  }


  /**
   * [create browser notification (it does not smoothly in firefox)]
   * @param  {String} [title='title']
   * @param  {String} [message='mycontent']
   * @param  {function} [onClose=()=>{}]
   */
  notifyUser(){
    chrome.notifications.create(
      'name-for-notification',
      {
        type: 'basic',
        iconUrl: 'images/on.png',
        title:   "Webtrack reminder",
        message: "15 minutes have passed!"
        // ,
        // contextMessage: "It's about time...",
        // eventTime: Date.now() + 10000,
        // buttons: [{
        //     title: "Yes, get me there",
        //     iconUrl: "images/on.png"
        // }, {
        //     title: "Get out of my way",
        //     iconUrl: "images/off.png"
        // }
        // ]
      }, function(id) {
        let myNotificationID = id;
        console.log(myNotificationID);
    });
  }

}//()