import browser from './browser' ; import Development from './dev-client' ; import MessageListener from './ext-agent' ; import { compressToBase64 , decompressFromBase64 } from 'lz-string' ; import { Channel , ChannelList , Settings , Ad , PendingItem , ParsedURL , AutoSkipSeconds } from './typings' ; import apiKeys from '../shared/api' ; const [ apiKey ] = apiKeys ; const instance = Math . random ( ) . toString ( 36 ) . substring ( 7 ) ; let settings ; let ads ; class SettingsManager { whitelist : Channels ; blacklist : Channels ; mutelist : Channels ; exclude : Channels ; muteAll : boolean ; skipOverlays : boolean ; skipAdErrors : boolean ; pauseAfterAd : boolean ; autoWhite : boolean ; autoSkip : boolean ; autoSkipSeconds : AutoSkipSeconds ; keyboardSkip : boolean ; verifyWl : boolean ; limitAds : boolean ; limitAdsQty : number ; forceWhite : boolean ; subscriptions : Channels ; constructor ( settings , subscriptions ) { settings = SettingsManager . sanitizeSettings ( settings ) ; this . whitelist = new Channels ( settings . whitelisted ) ; this . blacklist = new Channels ( settings . blacklisted ) ; this . exclude = new Channels ( settings . exclude ) ; this . mutelist = new Channels ( settings . muted ) ; this . muteAll = settings . muteAll ; this . skipOverlays = settings . skipOverlays ; this . skipAdErrors = settings . skipAdErrors ; this . pauseAfterAd = settings . pauseAfterAd ; this . autoSkip = settings . autoSkip ; this . autoSkipSeconds = settings . autoSkipSeconds ; this . autoWhite = settings . autoWhite ; this . keyboardSkip = settings . keyboardSkip ; this . verifyWl = settings . verifyWl ; this . limitAds = settings . limitAds ; this . limitAdsQty = settings . limitAdsQty ; this . forceWhite = settings . forceWhite ; this . subscriptions = new Channels ( subscriptions ) ; } static sanitizeSettings ( settings ? ) { settings = settings || { } as Settings ; if ( ! settings . whitelisted ) settings . whitelisted = [ ] ; if ( ! settings . blacklisted ) settings . blacklisted = [ ] ; if ( ! settings . muted ) settings . muted = [ ] ; if ( ! settings . exclude ) settings . exclude = [ ] ; settings . muteAll = ! ! settings . muteAll ; settings . pauseAfterAd = ! ! settings . pauseAfterAd ; settings . autoWhite = ! ! settings . autoWhite ; settings . skipOverlays = settings . skipOverlays === undefined ? true : ! ! settings . skipOverlays ; settings . skipAdErrors = settings . skipAdErrors === undefined ? true : ! ! settings . skipAdErrors ; settings . autoSkip = ! ! settings . autoSkip ; settings . keyboardSkip = settings . keyboardSkip === undefined ? true : ! ! settings . keyboardSkip ; settings . autoSkipSeconds = settings . autoSkipSeconds || 30 ; settings . verifyWl = settings . verifyWl === undefined ? true : ! ! settings . verifyWl ; settings . limitAds = ! ! settings . limitAds ; settings . limitAdsQty = ! isNaN ( settings . limitAdsQty ) && settings . limitAdsQty > 0 ? settings . limitAdsQty : 2 ; settings . forceWhite = ! ! settings . forceWhite ; return settings ; } static async getSettings ( throwOnOrigin = false ) { const store = await browser . storage . sync . get ( null ) ; if ( throwOnOrigin && ( ! store . instance || store . instance === instance ) ) throw 'The changes originated from same instance or are incomplete' ; const parsedStore = this . decompress ( store ) ; const parsedSubscriptions = JSON . parse ( localStorage ?. getItem ( 'subscriptions' ) || '[]' ) ; return [ ( parsedStore || { } ) as any as Settings , parsedSubscriptions as ChannelList ] ; } static decompress ( store ) { if ( store . algorithm === 'lz' && store . totalKeys ) { let compressedStr = '' ; for ( let i = 0 ; i < store . totalKeys ; i ++ ) { compressedStr += store [ 'lz_' + i ] ; } try { const decompressed = decompressFromBase64 ( compressedStr ) ; const parsed = JSON . parse ( decompressed ) ; return parsed ; } catch ( e ) { return null ; } } else { return null ; } } updateAll ( originTab ? , subscriptionsOnly = false ) { browser . tabs . query ( { } ) . then ( tabs => tabs . filter ( ( { id } ) => id !== undefined ) . forEach ( ( { id } ) => { const isOrigin = ! ! ( originTab && originTab . id === id ) ; if ( subscriptionsOnly ) { browser . tabs . sendMessage ( id , { action : 'subscriptions-update' , subscriptions : settings . subscriptions . get ( ) , initiator : isOrigin } ) . catch ( ( ) => { } ) ; } else { browser . tabs . sendMessage ( id , { action : 'update' , settings : settings . get ( ) , initiator : isOrigin } ) . catch ( ( ) => { } ) ; } } ) ) ; } suggest ( channels ) { browser . tabs . query ( { } ) . then ( tabs => tabs . forEach ( tab => { browser . tabs . sendMessage ( tab . id , { action : 'suggestions' , channels } ) ; } ) ) ; } highlightTab ( originTab ) { browser . tabs . query ( { } ) . then ( tabs => { const current = tabs . find ( ( { active } ) => active ) ; if ( ! current ) throw "No current tab" ; ( browser . tabs as any ) . highlight ( { tabs : [ current . index , originTab . index ] } ) ; } ) . catch ( error => console . error ( "Unable to highlight tab:" , error ) ) ; } static injectAll ( ) { browser . tabs . query ( { } ) . then ( tabs => tabs . forEach ( tab => browser . tabs . executeScript ( tab . id , { file : 'content.js' } ) . catch ( err => { } ) ) ) ; } toggleMuteAll ( on ) { this . muteAll = ! ! on ; } toggleAutoWhite ( on ) { this . autoWhite = ! ! on ; } toggleSkipOverlays ( on ) { this . skipOverlays = ! ! on ; } toggleSkipAdErrors ( on ) { this . skipAdErrors = ! ! on ; } togglePauseAfterAd ( on ) { this . pauseAfterAd = ! ! on ; } toggleVerifyWl ( on ) { this . verifyWl = ! ! on ; } toggleForceWl ( on ) { this . forceWhite = ! ! on ; } toggleAutoSkip ( on , seconds ) { this . autoSkip = ! ! on ; this . autoSkipSeconds = seconds === undefined ? 30 : ~ ~ seconds as AutoSkipSeconds ; } toggleKeyboardSkip ( on ) { this . keyboardSkip = ! ! on ; } toggleLimitAds ( on , limit ) { const nextLimit = ~ ~ limit ; this . limitAds = ! ! on ; this . limitAdsQty = ! isNaN ( nextLimit ) && nextLimit > 0 ? nextLimit : 2 ; } get ( ) { return { whitelisted : this . whitelist . get ( ) , blacklisted : this . blacklist . get ( ) , muted : this . mutelist . get ( ) , exclude : this . exclude . get ( ) , muteAll : this . muteAll , skipOverlays : this . skipOverlays , skipAdErrors : this . skipAdErrors , pauseAfterAd : this . pauseAfterAd , autoWhite : this . autoWhite , autoSkip : this . autoSkip , autoSkipSeconds : this . autoSkipSeconds , keyboardSkip : this . keyboardSkip , verifyWl : this . verifyWl , limitAds : this . limitAds , limitAdsQty : this . limitAdsQty , forceWhite : this . forceWhite } ; } getCompressed ( obj ) { const compressed = compressToBase64 ( JSON . stringify ( obj ) ) ; const max = 8192 / 2 ; const times = Math . ceil ( compressed . length / max ) ; const store = { } as any ; for ( let i = 0 ; i < times ; i ++ ) { store [ 'lz_' + i ] = compressed . substring ( i * max , ( i + 1 ) * max ) ; } store . algorithm = 'lz' ; store . totalKeys = times ; store . instance = instance ; return store ; } async save ( ) { const compressed = this . getCompressed ( this . get ( ) ) ; const keys = Object . keys ( compressed ) ; const start = performance . now ( ) ; await browser . storage . sync . clear ( ) ; for ( let key of keys ) { const t = { } as any ; t [ key ] = compressed [ key ] ; await browser . storage . sync . set ( t ) ; } const end = performance . now ( ) ; console . log ( 'Save duration:' , end - start ) ; } async saveLocal ( ) { localStorage ?. setItem ( 'subscriptions' , JSON . stringify ( this . subscriptions . get ( ) ) ) ; } } class Channels { private list : ChannelList ; constructor ( list ) { this . list = list . filter ( channel => this . is ( channel ) ) ; } contains ( id ) { return this . list . findIndex ( channel => channel . id === id ) !== - 1 ; } is ( channel ) { return ! ! channel && typeof channel === 'object' && typeof channel . id === 'string' && typeof channel . username === 'string' && typeof channel . display === 'string' ; } remove ( channels ) { const curCount = this . list . length ; const nextList = this . list . filter ( ( { id } ) => channels instanceof Array ? channels . indexOf ( id ) === - 1 : id !== channels ) ; this . list = nextList ; return nextList . length - curCount ; } add ( channelId ) { if ( this . is ( channelId ) && ! this . contains ( channelId . id ) ) { this . list = this . list . concat ( channelId ) ; return true ; } return false ; } get ( ) { return this . list ; } } class AdManager { ads : Array < Ad > ; pending : Array < PendingItem > ; apiAvailable : boolean ; constructor ( ) { this . ads = [ ] ; this . pending = [ ] ; this . apiAvailable = false ; this . checkPermissions ( ) ; } push ( ad ) { ad . title = typeof ad . title === 'string' ? ad . title : this . extractTitle ( ad . player_response ) ; this . ads = arrayTrim ( this . ads , 20 ) . map ( oldAd => this . trim ( oldAd ) ) . concat ( ad ) ; } trim ( ad ) { const nextAd = cloneObject ( ad ) ; const filter = [ "details" , "channelId" , "video_id" , "channel_url" , "length_seconds" , "ucid" , "author" , "blocked" , "title" , "timestamp" ] ; Object . keys ( nextAd ) . forEach ( key => filter . indexOf ( key ) === - 1 && delete nextAd [ key ] ) ; return nextAd ; } private extractTitle ( playerResponse ) { if ( ! playerResponse ) return '' ; try { const response = JSON . parse ( playerResponse ) ; if ( response . videoDetails && response . videoDetails . title ) return response . videoDetails . title ; return '' ; } catch ( e ) { return '' ; } } queue ( details ) { let resolver , rejector ; const promise = new Promise < Ad > ( ( resolve , reject ) => { resolver = resolve ; rejector = reject ; } ) ; this . pending = this . pending . concat ( { details , promise } ) ; promise . then ( ( ad ) => { this . pending = this . pending . filter ( item => promise !== item . promise ) ; this . push ( ad ) ; this . sendToTab ( ad . details . tabId , ad ) ; } ) . catch ( ad => { this . pending = this . pending . filter ( item => promise !== item . promise ) ; console . error ( 'No UCID available' , ad ) ; } ) ; return [ resolver , rejector ] ; } echo ( tabId , ad ) { this . push ( ad ) ; this . sendToTab ( tabId , ad ) ; } get ( ) { const pending = this . pending . map ( ( { promise } ) => promise ) ; return Promise . all ( pending ) . then ( ( ) => this . ads ) ; } sendToTab ( tabId , ad ) { browser . tabs . query ( { } ) . then ( tabs => tabs . some ( ( { id } ) => id === tabId && browser . tabs . sendMessage ( id , { action : 'ad-update' , ad : ad } ) ) ) ; } getLastAdFromTab ( tabId ) { const pending = this . pending . find ( ( { details } ) => details . tabId === tabId ) ; if ( pending ) return pending . promise ; const ad = this . ads . find ( ( { details } ) => details . tabId === tabId ) ; if ( ad ) return Promise . resolve ( ad ) ; return Promise . reject ( ) ; } findChannelFromPreviousAd ( id ) { const ad = this . ads . find ( item => item . channelId . id === id ) ; if ( ad ) { return ad . channelId ; } } findPrevAdByVideoId ( videoId ) { return this . ads . find ( ( { video_id } ) => video_id === videoId ) ; } fetchChannelTitle ( id ) { if ( this . apiAvailable ) { const url = 'https://content.googleapis.com' + '/youtube/v3/channels?part=snippet&id=' + id + '&key=' + apiKey ; return fetch ( url ) . then ( response => response . json ( ) ) . then ( json => { if ( json && json . items && json . items . length && json . items [ 0 ] . snippet && json . items [ 0 ] . snippet . title ) { return json . items [ 0 ] . snippet . title ; } else { return id ; } } ) . catch ( err => { console . error ( err ) ; return id ; } ) ; } else { return fetch ( 'https://www.youtube.com/channel/' + id ) . then ( response => response . text ( ) ) . then ( text => { const matches = text . match ( /\<meta name=\"title\" content=\"(.+)\"\>/ ) ; if ( matches && matches [ 1 ] ) { return matches [ 1 ] ; } else { return id ; } } ) . catch ( err => { console . error ( err ) ; return id ; } ) ; } } getChannelFromURL ( url ) { if ( ! url ) return '' ; const matches = url . match ( /\/channel\/([\w-]+)(?:\/|$|\?)/ ) ; if ( matches && matches [ 1 ] ) return matches [ 1 ] ; else return "" ; } getAdDetails ( url ) { const request = new XMLHttpRequest ( ) ; let channelTitle ; request . open ( 'GET' , url , false ) ; request . send ( null ) ; if ( request . status === 200 ) { const ad = ads . parseURL ( '?' + request . responseText ) . params ; if ( ad . player_response ) { try { ad . player_response = JSON . parse ( ad . player_response ) ; } catch ( e ) { } } ad . channelId = { id : ad . ucid || ads . getChannelFromURL ( ad . channel_url ) || ad . player_response ?. videoDetails ?. channelId , display : '' , username : '' } ; ad . title = ad . title || ad . player_response ?. videoDetails ?. title ; if ( ad . channelId . id ) { if ( ad . author ) { ad . channelId . display = ad . author ; channelTitle = Promise . resolve ( ) ; } else { const prevChannel = this . findChannelFromPreviousAd ( ad . channelId . id ) ; if ( prevChannel && prevChannel . display !== prevChannel . id ) { ad . channelId . display = prevChannel . display ; channelTitle = Promise . resolve ( ) ; } else { ad . channelId . display = ad . channelId . id ; channelTitle = ads . fetchChannelTitle ( ad . channelId . id ) . then ( title => ad . channelId . display = title ) . catch ( title => ad . channelId . display = title ) ; } } } return [ ad , channelTitle ] ; } else { return undefined ; } } getPrevAdDetails ( videoId ) { const prevAd = ads . findPrevAdByVideoId ( videoId ) ; if ( prevAd ) { const ad = cloneObject ( prevAd ) ; ad . timestamp = Date . now ( ) + '' ; return [ ad , Promise . resolve ( ) ] ; } return undefined ; } parseURL ( url ) { const params = { } as any ; const queryStart = url . indexOf ( '?' ) ; const pathname = url . substring ( url . lastIndexOf ( '/' , queryStart ) , queryStart ) ; const queries = new URLSearchParams ( url . substring ( queryStart + 1 ) ) ; for ( const [ key , value ] of queries . entries ( ) ) { params [ key ] = value ; } return { pathname : pathname , params : params } ; } processDetails ( details , forceBlock = false ) { let url = this . parseURL ( details . url ) ; let [ done , failed ] = this . queue ( details ) ; let shouldCancel = false ; if ( url . pathname === '/get_video_info' && url . params . video_id ) { const adDetails = this . getPrevAdDetails ( url . params . video_id ) || this . getAdDetails ( details . url ) ; if ( adDetails ) { const [ ad , completed ] = adDetails ; if ( ad . channelId . id ) { if ( settings . blacklist . contains ( ad . channelId . id ) || forceBlock ) { shouldCancel = true ; } completed . then ( ( ) => done ( ad ) ) . catch ( ( ) => done ( ad ) ) ; } if ( ! ad . timestamp ) ad . timestamp = Date . now ( ) + '' ; ad . video_id = url . params . video_id ; ad . details = details ; ad . blocked = shouldCancel ; if ( ! ad . channelId . id ) { failed ( ad ) ; } } } else { failed ( 'Invalid request: ' + url ) ; } return shouldCancel ; } checkPermissions ( ) { const neededPerms = { origins : [ '*://*.content.googleapis.com/' ] } ; return browser . permissions . contains ( neededPerms ) . then ( ( granted ) => this . apiAvailable = granted ) ; } } browser . storage . local . get ( { } ) ; SettingsManager . getSettings ( ) . then ( ( [ _settings , _subscriptions ] ) => { console . log ( 'Loaded with settings:' , _settings , 'and cached subscriptions:' , _subscriptions ) ; settings = new SettingsManager ( _settings , _subscriptions ) ; ads = new AdManager ( ) ; const listener = new MessageListener ( ) ; listener . onAction ( 'set' ) . on ( 'add-white' , ( _ , channelId ) => settings . whitelist . add ( channelId ) ) . on ( 'add-black' , ( _ , channelId ) => settings . blacklist . add ( channelId ) ) . on ( 'add-mute' , ( _ , channelId ) => settings . mutelist . add ( channelId ) ) . on ( 'add-exclude' , ( _ , channelId ) => settings . exclude . add ( channelId ) ) . on ( 'remove-exclude' , ( _ , channel ) => settings . exclude . remove ( channel instanceof Array ? channel : channel . id ) ) . on ( 'remove-mute' , ( _ , channel ) => settings . mutelist . remove ( channel instanceof Array ? channel : channel . id ) ) . on ( 'remove-white' , ( _ , channel ) => settings . whitelist . remove ( channel instanceof Array ? channel : channel . id ) ) . on ( 'remove-black' , ( _ , channel ) => settings . blacklist . remove ( channel instanceof Array ? channel : channel . id ) ) . on ( 'suggest-white' , ( sender , channels ) => { settings . suggest ( channels ) ; browser . tabs . remove ( sender . tab . id ) ; } ) . on ( 'bulk' , ( _ , nextSettings ) => settings = new SettingsManager ( nextSettings , settings . subscriptions . get ( ) ) ) . on ( 'reset' , ( ) => settings = new SettingsManager ( { } as Settings , [ ] ) ) . on ( 'mute-all' , ( _ , shouldMute ) => settings . toggleMuteAll ( shouldMute ) ) . on ( 'auto-whitelist' , ( _ , shouldAutoWhite ) => settings . toggleAutoWhite ( shouldAutoWhite ) ) . on ( 'skip-overlays' , ( _ , shouldSkip ) => settings . toggleSkipOverlays ( shouldSkip ) ) . on ( 'skip-ad-errors' , ( _ , shouldSkip ) => settings . toggleSkipAdErrors ( shouldSkip ) ) . on ( 'pause-after-ad' , ( _ , shouldPause ) => settings . togglePauseAfterAd ( shouldPause ) ) . on ( 'auto-skip' , ( _ , { autoSkip , autoSkipSeconds } ) => settings . toggleAutoSkip ( autoSkip , autoSkipSeconds ) ) . on ( 'limit-ads' , ( _ , { limitAds , limitAdsQty } ) => settings . toggleLimitAds ( limitAds , limitAdsQty ) ) . on ( 'keyboard-skip' , ( _ , nextKeyboardSkip ) => settings . toggleKeyboardSkip ( nextKeyboardSkip ) ) . on ( 'verify-wl' , ( _ , shouldVerify ) => settings . toggleVerifyWl ( shouldVerify ) ) . on ( 'force-wl' , ( _ , shouldForce ) => settings . toggleForceWl ( shouldForce ) ) . onAll ( sender => { settings . save ( ) ; settings . updateAll ( sender . tab ) ; return settings . get ( ) ; } ) ; listener . onAction ( 'cache' ) . on ( 'suggest-subscriptions' , ( _ , channels ) => { settings . subscriptions = new Channels ( channels ) ; } ) . on ( 'add-subscriptions' , ( _ , channelId ) => settings . subscriptions . add ( channelId ) ) . on ( 'remove-subscriptions' , ( _ , channelId ) => settings . subscriptions . remove ( channelId . id ) ) . onAll ( ( sender ) => { settings . updateAll ( sender . tab , true ) ; return settings . saveLocal ( ) ; } ) ; listener . onAction ( 'get' ) . on ( 'settings' , ( ) => settings . get ( ) ) . on ( 'settings+subs' , ( ) => ( { settings : settings . get ( ) , subscriptions : settings . subscriptions . get ( ) } ) ) . on ( 'ads' , ( ) => ads . get ( ) ) ; listener . onAction ( 'tab' ) . on ( 'settings' , ( sender , tab ) => browser . tabs . create ( { url : browser . runtime . getURL ( 'settings.html' ) + ( tab ? '#' + tab : '' ) , active : true } ) . then ( ( ) => browser . tabs . remove ( sender . tab . id ) ) ) . on ( 'mute' , ( sender , shouldMute ) => browser . tabs . update ( sender . tab . id , { muted : shouldMute } ) ) . on ( 'last-ad' , sender => ads . getLastAdFromTab ( sender . tab . id ) ) . on ( 'echo-ad' , ( sender , ad ) => ads . echo ( sender . tab . id , ad ) ) . on ( 'highlight' , sender => settings . highlightTab ( sender . tab ) ) ; listener . onAction ( 'permission' ) . on ( 'google-api' , ( ) => ads . checkPermissions ( ) ) ; listener . start ( ) ; browser . webRequest . onBeforeSendHeaders . addListener ( details => { if ( details . tabId === - 1 ) return ; const cancel = ads . processDetails ( details ) ; return { cancel } ; } , { urls : [ '*://www.youtube.com/get_video_info?*' ] } , [ 'blocking' ] ) ; browser . storage . onChanged . addListener ( ( ) => { SettingsManager . getSettings ( true ) . then ( ( [ nextSettings , nextSubscriptions ] ) => { settings = new SettingsManager ( nextSettings , nextSubscriptions ) ; settings . updateAll ( ) ; } ) ; } ) ; } ) ; SettingsManager . injectAll ( ) ; if ( Development && Development . detectedDevMode ( ) ) { const started = Date . now ( ) ; const client = new Development ( ) ; client . on ( 'reload' , ( ) => { client . close ( ) ; browser . runtime . reload ( ) ; } ) . on ( 'partialreload' , ( ) => { SettingsManager . injectAll ( ) ; console . log ( 'Re-injected scripts' ) ; } ) ; client . connect ( ) ; ( window as any ) . dev = ( ( ) => client . originalLog ( 'Started' , ( Date . now ( ) - started ) / 60000 , 'minutes ago' ) ) as any ; console . log ( '[' , started , ']: Development mode. Instance' , instance ) ; } function cloneObject < T > ( obj ) { return JSON . parse ( JSON . stringify ( obj ) ) ; } function arrayTrim < T > ( array , max ) { return array . slice ( Math . max ( array . length - max , 0 ) , array . length ) ; } function pushTrim < T > ( array , item , max ) { const arr = arrayTrim ( array , max - 1 ) ; arr . push ( item ) ; return arr ; }