Files
googledocs-darkmode/background.js
2026-03-12 11:29:26 -04:00

5332 lines
157 KiB
JavaScript

;(function () {
'use strict'
const userAgent =
typeof navigator === 'undefined'
? 'some useragent'
: navigator.userAgent.toLowerCase()
const platform =
typeof navigator === 'undefined'
? 'some platform'
: navigator.platform.toLowerCase()
const isChromium =
userAgent.includes('chrome') || userAgent.includes('chromium')
const isThunderbird = userAgent.includes('thunderbird')
const isFirefox =
userAgent.includes('firefox') ||
userAgent.includes('librewolf') ||
isThunderbird
userAgent.includes('vivaldi')
userAgent.includes('yabrowser')
const isOpera = userAgent.includes('opr') || userAgent.includes('opera')
const isEdge = userAgent.includes('edg')
userAgent.includes('safari') && !isChromium
const isWindows = platform.startsWith('win')
const isMacOS = platform.startsWith('mac')
userAgent.includes('mobile')
const chromiumVersion = (() => {
const m = userAgent.match(/chrom[e|ium]\/([^ ]+)/)
if (m && m[1]) {
return m[1]
}
return ''
})()
;(() => {
try {
document.querySelector(':defined')
return true
} catch (err) {
return false
}
})()
function compareChromeVersions($a, $b) {
const a = $a.split('.').map((x) => parseInt(x))
const b = $b.split('.').map((x) => parseInt(x))
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return a[i] < b[i] ? -1 : 1
}
}
return 0
}
const isXMLHttpRequestSupported = typeof XMLHttpRequest === 'function'
const isFetchSupported = typeof fetch === 'function'
const isMV3 =
globalThis.chrome &&
globalThis.chrome.runtime &&
globalThis.chrome.runtime.getManifest &&
globalThis.chrome.runtime.getManifest().manifest_version === 3
const isCSSColorSchemePropSupported = (() => {
if (typeof document === 'undefined') {
return false
}
const el = document.createElement('div')
el.setAttribute('style', 'color-scheme: dark')
return el.style && el.style.colorScheme === 'dark'
})()
async function getOKResponse(url, mimeType, origin) {
const response = await fetch(url, {
cache: 'force-cache',
credentials: 'omit',
referrer: origin,
})
if (
isFirefox &&
mimeType === 'text/css' &&
url.startsWith('moz-extension://') &&
url.endsWith('.css')
) {
return response
}
if (
mimeType &&
!response.headers.get('Content-Type').startsWith(mimeType)
) {
throw new Error(`Mime type mismatch when loading ${url}`)
}
if (!response.ok) {
throw new Error(
`Unable to load ${url} ${response.status} ${response.statusText}`
)
}
return response
}
async function loadAsDataURL(url, mimeType) {
const response = await getOKResponse(url, mimeType)
return await readResponseAsDataURL(response)
}
async function readResponseAsDataURL(response) {
const blob = await response.blob()
const dataURL = await new Promise((resolve) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result)
reader.readAsDataURL(blob)
})
return dataURL
}
async function loadAsText(url, mimeType, origin) {
const response = await getOKResponse(url, mimeType, origin)
return await response.text()
}
function parseArray(text) {
return text
.replace(/\r/g, '')
.split('\n')
.map((s) => s.trim())
.filter((s) => s)
}
function formatArray(arr) {
return arr.concat('').join('\n')
}
function getStringSize(value) {
return value.length * 2
}
function parse24HTime(time) {
return time.split(':').map((x) => parseInt(x))
}
function compareTime(time1, time2) {
if (time1[0] === time2[0] && time1[1] === time2[1]) {
return 0
}
if (time1[0] < time2[0] || (time1[0] === time2[0] && time1[1] < time2[1])) {
return -1
}
return 1
}
function nextTimeInterval(time0, time1, date = new Date()) {
const a = parse24HTime(time0)
const b = parse24HTime(time1)
const t = [date.getHours(), date.getMinutes()]
if (compareTime(a, b) > 0) {
return nextTimeInterval(time1, time0, date)
}
if (compareTime(a, b) === 0) {
return null
}
if (compareTime(t, a) < 0) {
date.setHours(a[0])
date.setMinutes(a[1])
date.setSeconds(0)
date.setMilliseconds(0)
return date.getTime()
}
if (compareTime(t, b) < 0) {
date.setHours(b[0])
date.setMinutes(b[1])
date.setSeconds(0)
date.setMilliseconds(0)
return date.getTime()
}
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate() + 1,
a[0],
a[1]
).getTime()
}
function isInTimeIntervalLocal(time0, time1, date = new Date()) {
const a = parse24HTime(time0)
const b = parse24HTime(time1)
const t = [date.getHours(), date.getMinutes()]
if (compareTime(a, b) > 0) {
return compareTime(a, t) <= 0 || compareTime(t, b) < 0
}
return compareTime(a, t) <= 0 && compareTime(t, b) < 0
}
function isInTimeIntervalUTC(time0, time1, timestamp) {
if (time1 < time0) {
return timestamp <= time1 || time0 <= timestamp
}
return time0 < timestamp && timestamp < time1
}
function getDuration(time) {
let duration = 0
if (time.seconds) {
duration += time.seconds * 1000
}
if (time.minutes) {
duration += time.minutes * 60 * 1000
}
if (time.hours) {
duration += time.hours * 60 * 60 * 1000
}
if (time.days) {
duration += time.days * 24 * 60 * 60 * 1000
}
return duration
}
function getDurationInMinutes(time) {
return getDuration(time) / 1000 / 60
}
function getSunsetSunriseUTCTime(latitude, longitude, date) {
const dec31 = Date.UTC(date.getUTCFullYear(), 0, 0, 0, 0, 0, 0)
const oneDay = getDuration({ days: 1 })
const dayOfYear = Math.floor((date.getTime() - dec31) / oneDay)
const zenith = 90.83333333333333
const D2R = Math.PI / 180
const R2D = 180 / Math.PI
const lnHour = longitude / 15
function getTime(isSunrise) {
const t = dayOfYear + ((isSunrise ? 6 : 18) - lnHour) / 24
const M = 0.9856 * t - 3.289
let L =
M + 1.916 * Math.sin(M * D2R) + 0.02 * Math.sin(2 * M * D2R) + 282.634
if (L > 360) {
L -= 360
} else if (L < 0) {
L += 360
}
let RA = R2D * Math.atan(0.91764 * Math.tan(L * D2R))
if (RA > 360) {
RA -= 360
} else if (RA < 0) {
RA += 360
}
const Lquadrant = Math.floor(L / 90) * 90
const RAquadrant = Math.floor(RA / 90) * 90
RA += Lquadrant - RAquadrant
RA /= 15
const sinDec = 0.39782 * Math.sin(L * D2R)
const cosDec = Math.cos(Math.asin(sinDec))
const cosH =
(Math.cos(zenith * D2R) - sinDec * Math.sin(latitude * D2R)) /
(cosDec * Math.cos(latitude * D2R))
if (cosH > 1) {
return {
alwaysDay: false,
alwaysNight: true,
time: 0,
}
} else if (cosH < -1) {
return {
alwaysDay: true,
alwaysNight: false,
time: 0,
}
}
const H =
(isSunrise ? 360 - R2D * Math.acos(cosH) : R2D * Math.acos(cosH)) / 15
const T = H + RA - 0.06571 * t - 6.622
let UT = T - lnHour
if (UT > 24) {
UT -= 24
} else if (UT < 0) {
UT += 24
}
return {
alwaysDay: false,
alwaysNight: false,
time: Math.round(UT * getDuration({ hours: 1 })),
}
}
const sunriseTime = getTime(true)
const sunsetTime = getTime(false)
if (sunriseTime.alwaysDay || sunsetTime.alwaysDay) {
return {
alwaysDay: true,
}
} else if (sunriseTime.alwaysNight || sunsetTime.alwaysNight) {
return {
alwaysNight: true,
}
}
return {
sunriseTime: sunriseTime.time,
sunsetTime: sunsetTime.time,
}
}
function isNightAtLocation(latitude, longitude, date = new Date()) {
const time = getSunsetSunriseUTCTime(latitude, longitude, date)
if (time.alwaysDay) {
return false
} else if (time.alwaysNight) {
return true
}
const sunriseTime = time.sunriseTime
const sunsetTime = time.sunsetTime
const currentTime =
date.getUTCHours() * getDuration({ hours: 1 }) +
date.getUTCMinutes() * getDuration({ minutes: 1 }) +
date.getUTCSeconds() * getDuration({ seconds: 1 }) +
date.getUTCMilliseconds()
return isInTimeIntervalUTC(sunsetTime, sunriseTime, currentTime)
}
function nextTimeChangeAtLocation(latitude, longitude, date = new Date()) {
const time = getSunsetSunriseUTCTime(latitude, longitude, date)
if (time.alwaysDay) {
return date.getTime() + getDuration({ days: 1 })
} else if (time.alwaysNight) {
return date.getTime() + getDuration({ days: 1 })
}
const [firstTimeOnDay, lastTimeOnDay] =
time.sunriseTime < time.sunsetTime
? [time.sunriseTime, time.sunsetTime]
: [time.sunsetTime, time.sunriseTime]
const currentTime =
date.getUTCHours() * getDuration({ hours: 1 }) +
date.getUTCMinutes() * getDuration({ minutes: 1 }) +
date.getUTCSeconds() * getDuration({ seconds: 1 }) +
date.getUTCMilliseconds()
if (currentTime <= firstTimeOnDay) {
return Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
0,
0,
0,
firstTimeOnDay
)
}
if (currentTime <= lastTimeOnDay) {
return Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
0,
0,
0,
lastTimeOnDay
)
}
return Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate() + 1,
0,
0,
0,
firstTimeOnDay
)
}
async function readText(params) {
return new Promise((resolve, reject) => {
if (isXMLHttpRequestSupported) {
const request = new XMLHttpRequest()
request.overrideMimeType('text/plain')
request.open('GET', params.url, true)
request.onload = () => {
if (request.status >= 200 && request.status < 300) {
resolve(request.responseText)
} else {
reject(new Error(`${request.status}: ${request.statusText}`))
}
}
request.onerror = () =>
reject(new Error(`${request.status}: ${request.statusText}`))
if (params.timeout) {
request.timeout = params.timeout
request.ontimeout = () =>
reject(new Error('File loading stopped due to timeout'))
}
request.send()
} else if (isFetchSupported) {
let abortController
let signal
let timedOut = false
if (params.timeout) {
abortController = new AbortController()
signal = abortController.signal
setTimeout(() => {
abortController.abort()
timedOut = true
}, params.timeout)
}
fetch(params.url, { signal })
.then((response) => {
if (response.status >= 200 && response.status < 300) {
resolve(response.text())
} else {
reject(new Error(`${response.status}: ${response.statusText}`))
}
})
.catch((error) => {
if (timedOut) {
reject(new Error('File loading stopped due to timeout'))
} else {
reject(error)
}
})
} else {
reject(
new Error(`Neither XMLHttpRequest nor Fetch API are accessible!`)
)
}
})
}
class LimitedCacheStorage {
constructor() {
this.bytesInUse = 0
this.records = new Map()
this.alarmIsActive = false
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === LimitedCacheStorage.ALARM_NAME) {
this.alarmIsActive = false
this.removeExpiredRecords()
}
})
}
ensureAlarmIsScheduled() {
if (!this.alarmIsActive) {
chrome.alarms.create(LimitedCacheStorage.ALARM_NAME, {
delayInMinutes: 1,
})
this.alarmIsActive = true
}
}
has(url) {
return this.records.has(url)
}
get(url) {
if (this.records.has(url)) {
const record = this.records.get(url)
record.expires = Date.now() + LimitedCacheStorage.TTL
this.records.delete(url)
this.records.set(url, record)
return record.value
}
return null
}
set(url, value) {
this.ensureAlarmIsScheduled()
const size = getStringSize(value)
if (size > LimitedCacheStorage.QUOTA_BYTES) {
return
}
for (const [url, record] of this.records) {
if (this.bytesInUse + size > LimitedCacheStorage.QUOTA_BYTES) {
this.records.delete(url)
this.bytesInUse -= record.size
} else {
break
}
}
const expires = Date.now() + LimitedCacheStorage.TTL
this.records.set(url, { url, value, size, expires })
this.bytesInUse += size
}
removeExpiredRecords() {
const now = Date.now()
for (const [url, record] of this.records) {
if (record.expires < now) {
this.records.delete(url)
this.bytesInUse -= record.size
} else {
break
}
}
if (this.records.size !== 0) {
this.ensureAlarmIsScheduled()
}
}
}
LimitedCacheStorage.QUOTA_BYTES =
(navigator.deviceMemory || 4) * 16 * 1024 * 1024
LimitedCacheStorage.TTL = getDuration({ minutes: 10 })
LimitedCacheStorage.ALARM_NAME = 'network'
function createFileLoader() {
const caches = {
'data-url': new LimitedCacheStorage(),
text: new LimitedCacheStorage(),
}
const loaders = {
'data-url': loadAsDataURL,
text: loadAsText,
}
async function get({ url, responseType, mimeType, origin }) {
const cache = caches[responseType]
const load = loaders[responseType]
if (cache.has(url)) {
return cache.get(url)
}
const data = await load(url, mimeType, origin)
cache.set(url, data)
return data
}
return { get }
}
function isIPV6(url) {
const openingBracketIndex = url.indexOf('[')
if (openingBracketIndex < 0) {
return false
}
const queryIndex = url.indexOf('?')
if (queryIndex >= 0 && openingBracketIndex > queryIndex) {
return false
}
return true
}
const ipV6HostRegex = /\[.*?\](\:\d+)?/
function compareIPV6(firstURL, secondURL) {
const firstHost = firstURL.match(ipV6HostRegex)[0]
const secondHost = secondURL.match(ipV6HostRegex)[0]
return firstHost === secondHost
}
function getURLHostOrProtocol($url) {
const url = new URL($url)
if (url.host) {
return url.host
} else if (url.protocol === 'file:') {
return url.pathname
}
return url.protocol
}
function compareURLPatterns(a, b) {
return a.localeCompare(b)
}
function isURLInList(url, list) {
for (let i = 0; i < list.length; i++) {
if (isURLMatched(url, list[i])) {
return true
}
}
return false
}
function isURLMatched(url, urlTemplate) {
const isFirstIPV6 = isIPV6(url)
const isSecondIPV6 = isIPV6(urlTemplate)
if (isFirstIPV6 && isSecondIPV6) {
return compareIPV6(url, urlTemplate)
} else if (!isFirstIPV6 && !isSecondIPV6) {
const regex = createUrlRegex(urlTemplate)
return Boolean(url.match(regex))
}
return false
}
function createUrlRegex(urlTemplate) {
urlTemplate = urlTemplate.trim()
const exactBeginning = urlTemplate[0] === '^'
const exactEnding = urlTemplate[urlTemplate.length - 1] === '$'
urlTemplate = urlTemplate
.replace(/^\^/, '')
.replace(/\$$/, '')
.replace(/^.*?\/{2,3}/, '')
.replace(/\?.*$/, '')
.replace(/\/$/, '')
let slashIndex
let beforeSlash
let afterSlash
if ((slashIndex = urlTemplate.indexOf('/')) >= 0) {
beforeSlash = urlTemplate.substring(0, slashIndex)
afterSlash = urlTemplate.replace(/\$/g, '').substring(slashIndex)
} else {
beforeSlash = urlTemplate.replace(/\$/g, '')
}
let result = exactBeginning
? '^(.*?\\:\\/{2,3})?'
: '^(.*?\\:\\/{2,3})?([^/]*?\\.)?'
const hostParts = beforeSlash.split('.')
result += '('
for (let i = 0; i < hostParts.length; i++) {
if (hostParts[i] === '*') {
hostParts[i] = '[^\\.\\/]+?'
}
}
result += hostParts.join('\\.')
result += ')'
if (afterSlash) {
result += '('
result += afterSlash.replace('/', '\\/')
result += ')'
}
result += exactEnding ? '(\\/?(\\?[^/]*?)?)$' : '(\\/?.*?)$'
return new RegExp(result, 'i')
}
function isPDF(url) {
if (url.includes('.pdf')) {
if (url.includes('?')) {
url = url.substring(0, url.lastIndexOf('?'))
}
if (url.includes('#')) {
url = url.substring(0, url.lastIndexOf('#'))
}
if (
(url.match(/(wikipedia|wikimedia).org/i) &&
url.match(
/(wikipedia|wikimedia)\.org\/.*\/[a-z]+\:[^\:\/]+\.pdf/i
)) ||
(url.match(/timetravel\.mementoweb\.org\/reconstruct/i) &&
url.match(/\.pdf$/i))
) {
return false
}
if (url.endsWith('.pdf')) {
for (let i = url.length; i > 0; i--) {
if (url[i] === '=') {
return false
} else if (url[i] === '/') {
return true
}
}
} else {
return false
}
}
return false
}
function isURLEnabled(
url,
userSettings,
{ isProtected, isInDarkList, isDarkThemeDetected }
) {
if (isProtected && !userSettings.enableForProtectedPages) {
return false
}
if (isThunderbird) {
return true
}
if (isPDF(url)) {
return userSettings.enableForPDF
}
const isURLInUserList = isURLInList(url, userSettings.siteList)
const isURLInEnabledList = isURLInList(url, userSettings.siteListEnabled)
if (userSettings.applyToListedOnly) {
return isURLInEnabledList || isURLInUserList
}
if (isURLInEnabledList) {
return true
}
if (isInDarkList || (userSettings.detectDarkTheme && isDarkThemeDetected)) {
return false
}
return !isURLInUserList
}
function isFullyQualifiedDomain(candidate) {
return /^[a-z0-9.-]+$/.test(candidate)
}
function parseSitesFixesConfig(text, options) {
const sites = []
const blocks = text.replace(/\r/g, '').split(/^\s*={2,}\s*$/gm)
blocks.forEach((block) => {
const lines = block.split('\n')
const commandIndices = []
lines.forEach((ln, i) => {
if (ln.match(/^[A-Z]+(\s[A-Z]+){0,2}$/)) {
commandIndices.push(i)
}
})
if (commandIndices.length === 0) {
return
}
const siteFix = {
url: parseArray(lines.slice(0, commandIndices[0]).join('\n')),
}
commandIndices.forEach((commandIndex, i) => {
const command = lines[commandIndex].trim()
const valueText = lines
.slice(
commandIndex + 1,
i === commandIndices.length - 1
? lines.length
: commandIndices[i + 1]
)
.join('\n')
const prop = options.getCommandPropName(command)
if (!prop) {
return
}
const value = options.parseCommandValue(command, valueText)
siteFix[prop] = value
})
sites.push(siteFix)
})
return sites
}
function getDomain(url) {
try {
return new URL(url).hostname.toLowerCase()
} catch (error) {
return url.split('/')[0].toLowerCase()
}
}
function encodeOffsets(offsets) {
return offsets
.map(([offset, length]) => {
const stringOffset = offset.toString(36)
const stringLength = length.toString(36)
return (
'0'.repeat(4 - stringOffset.length) +
stringOffset +
'0'.repeat(3 - stringLength.length) +
stringLength
)
})
.join('')
}
function decodeOffset(offsets, index) {
const base = (4 + 3) * index
const offset = parseInt(offsets.substring(base + 0, base + 4), 36)
const length = parseInt(offsets.substring(base + 4, base + 4 + 3), 36)
return [offset, offset + length]
}
function indexSitesFixesConfig(text) {
const domains = {}
const domainPatterns = {}
const offsets = []
function processBlock(recordStart, recordEnd, index) {
const block = text.substring(recordStart, recordEnd)
const lines = block.split('\n')
const commandIndices = []
lines.forEach((ln, i) => {
if (ln.match(/^[A-Z]+(\s[A-Z]+){0,2}$/)) {
commandIndices.push(i)
}
})
if (commandIndices.length === 0) {
return
}
const urls = parseArray(lines.slice(0, commandIndices[0]).join('\n'))
for (const url of urls) {
const domain = getDomain(url)
if (isFullyQualifiedDomain(domain)) {
if (!domains[domain]) {
domains[domain] = index
} else if (
typeof domains[domain] === 'number' &&
domains[domain] !== index
) {
domains[domain] = [domains[domain], index]
} else if (
typeof domains[domain] === 'object' &&
!domains[domain].includes(index)
) {
domains[domain].push(index)
}
continue
}
if (!domainPatterns[domain]) {
domainPatterns[domain] = index
} else if (
typeof domainPatterns[domain] === 'number' &&
domainPatterns[domain] !== index
) {
domainPatterns[domain] = [domainPatterns[domain], index]
} else if (
typeof domainPatterns[domain] === 'object' &&
!domainPatterns[domain].includes(index)
) {
domainPatterns[domain].push(index)
}
}
offsets.push([recordStart, recordEnd - recordStart])
}
let recordStart = 0
const delimiterRegex = /^\s*={2,}\s*$/gm
let delimiter
let count = 0
while ((delimiter = delimiterRegex.exec(text))) {
const nextDelimiterStart = delimiter.index
const nextDelimiterEnd = delimiter.index + delimiter[0].length
processBlock(recordStart, nextDelimiterStart, count)
recordStart = nextDelimiterEnd
count++
}
processBlock(recordStart, text.length, count)
return {
offsets: encodeOffsets(offsets),
domains,
domainPatterns,
cache: {},
}
}
function parseSiteFixConfig(text, options, recordStart, recordEnd) {
const block = text.substring(recordStart, recordEnd)
return parseSitesFixesConfig(block, options)[0]
}
function getSitesFixesFor(url, text, index, options) {
const records = []
let recordIds = []
const domain = getDomain(url)
for (const pattern of Object.keys(index.domainPatterns)) {
if (isURLMatched(url, pattern)) {
recordIds = recordIds.concat(index.domainPatterns[pattern])
}
}
const labels = domain.split('.')
for (let i = 0; i < labels.length; i++) {
const substring = labels.slice(i).join('.')
if (index.domains[substring] && isURLMatched(url, substring)) {
recordIds = recordIds.concat(index.domains[substring])
}
}
const set = new Set()
for (const id of recordIds) {
if (set.has(id)) {
continue
}
set.add(id)
if (!index.cache[id]) {
const [start, end] = decodeOffset(index.offsets, id)
index.cache[id] = parseSiteFixConfig(text, options, start, end)
}
records.push(index.cache[id])
}
return records
}
const SEPERATOR = '='.repeat(32)
const backgroundPropertyLength = 'background: '.length
const textPropertyLength = 'text: '.length
const humanizeNumber = (number) => {
if (number > 3) {
return `${number}th`
}
switch (number) {
case 0:
return '0'
case 1:
return '1st'
case 2:
return '2nd'
case 3:
return '3rd'
}
}
const isValidHexColor = (color) => {
return /^#([0-9a-fA-F]{3}){1,2}$/.test(color)
}
function ParseColorSchemeConfig(config) {
const sections = config.split(`${SEPERATOR}\n\n`)
const definedColorSchemeNames = new Set()
let lastDefinedColorSchemeName = ''
const definedColorSchemes = {
light: {},
dark: {},
}
let interrupt = false
let error = null
const throwError = (message) => {
if (!interrupt) {
interrupt = true
error = message
}
}
sections.forEach((section) => {
if (interrupt) {
return
}
const lines = section.split('\n')
const name = lines[0]
if (!name) {
throwError('No color scheme name was found.')
return
}
if (definedColorSchemeNames.has(name)) {
throwError(`The color scheme name "${name}" is already defined.`)
return
}
if (
lastDefinedColorSchemeName &&
lastDefinedColorSchemeName !== 'Default' &&
name.localeCompare(lastDefinedColorSchemeName) < 0
) {
throwError(
`The color scheme name "${name}" is not in alphabetical order.`
)
return
}
lastDefinedColorSchemeName = name
definedColorSchemeNames.add(name)
if (lines[1]) {
throwError(
`The second line of the color scheme "${name}" is not empty.`
)
return
}
const checkVariant = (lineIndex, isSecondVariant) => {
const variant = lines[lineIndex]
if (!variant) {
throwError(
`The third line of the color scheme "${name}" is not defined.`
)
return
}
if (
variant !== 'LIGHT' &&
variant !== 'DARK' &&
isSecondVariant &&
variant === 'Light'
) {
throwError(
`The ${humanizeNumber(
lineIndex
)} line of the color scheme "${name}" is not a valid variant.`
)
return
}
const firstProperty = lines[lineIndex + 1]
if (!firstProperty) {
throwError(
`The ${humanizeNumber(
lineIndex + 1
)} line of the color scheme "${name}" is not defined.`
)
return
}
if (!firstProperty.startsWith('background: ')) {
throwError(
`The ${humanizeNumber(
lineIndex + 1
)} line of the color scheme "${name}" is not background-color property.`
)
return
}
const backgroundColor = firstProperty.slice(backgroundPropertyLength)
if (!isValidHexColor(backgroundColor)) {
throwError(
`The ${humanizeNumber(
lineIndex + 1
)} line of the color scheme "${name}" is not a valid hex color.`
)
return
}
const secondProperty = lines[lineIndex + 2]
if (!secondProperty) {
throwError(
`The ${humanizeNumber(
lineIndex + 2
)} line of the color scheme "${name}" is not defined.`
)
return
}
if (!secondProperty.startsWith('text: ')) {
throwError(
`The ${humanizeNumber(
lineIndex + 2
)} line of the color scheme "${name}" is not text-color property.`
)
return
}
const textColor = secondProperty.slice(textPropertyLength)
if (!isValidHexColor(textColor)) {
throwError(
`The ${humanizeNumber(
lineIndex + 2
)} line of the color scheme "${name}" is not a valid hex color.`
)
return
}
return {
backgroundColor,
textColor,
variant,
}
}
const firstVariant = checkVariant(2, false)
const isFirstVariantLight = firstVariant.variant === 'LIGHT'
delete firstVariant.variant
if (interrupt) {
return
}
let secondVariant = null
let isSecondVariantLight = false
if (lines[6]) {
secondVariant = checkVariant(6, true)
isSecondVariantLight = secondVariant.variant === 'LIGHT'
delete secondVariant.variant
if (interrupt) {
return
}
if (lines.length > 11 || lines[9] || lines[10]) {
throwError(`The color scheme "${name}" doesn't end with 1 new line.`)
return
}
} else if (lines.length > 7) {
throwError(`The color scheme "${name}" doesn't end with 1 new line.`)
return
}
if (secondVariant) {
if (isFirstVariantLight === isSecondVariantLight) {
throwError(`The color scheme "${name}" has the same variant twice.`)
return
}
if (isFirstVariantLight) {
definedColorSchemes.light[name] = firstVariant
definedColorSchemes.dark[name] = secondVariant
} else {
definedColorSchemes.light[name] = secondVariant
definedColorSchemes.dark[name] = firstVariant
}
} else if (isFirstVariantLight) {
definedColorSchemes.light[name] = firstVariant
} else {
definedColorSchemes.dark[name] = firstVariant
}
})
return { result: definedColorSchemes, error: error }
}
function logInfo(...args) {}
function logWarn(...args) {}
var ThemeEngines = {
cssFilter: 'cssFilter',
svgFilter: 'svgFilter',
staticTheme: 'staticTheme',
dynamicTheme: 'dynamicTheme',
}
const DEFAULT_COLORS = {
darkScheme: {
background: '#181a1b',
text: '#e8e6e3',
},
lightScheme: {
background: '#dcdad7',
text: '#181a1b',
},
}
const DEFAULT_THEME = {
mode: 1,
brightness: 100,
contrast: 100,
grayscale: 0,
sepia: 0,
useFont: false,
fontFamily: isMacOS
? 'Helvetica Neue'
: isWindows
? 'Segoe UI'
: 'Open Sans',
textStroke: 0,
engine: ThemeEngines.dynamicTheme,
stylesheet: '',
darkSchemeBackgroundColor: DEFAULT_COLORS.darkScheme.background,
darkSchemeTextColor: DEFAULT_COLORS.darkScheme.text,
lightSchemeBackgroundColor: DEFAULT_COLORS.lightScheme.background,
lightSchemeTextColor: DEFAULT_COLORS.lightScheme.text,
scrollbarColor: isMacOS ? '' : 'auto',
selectionColor: 'auto',
styleSystemControls: !isCSSColorSchemePropSupported,
lightColorScheme: 'Default',
darkColorScheme: 'Default',
immediateModify: false,
}
const DEFAULT_COLORSCHEME = {
light: {
Default: {
backgroundColor: DEFAULT_COLORS.lightScheme.background,
textColor: DEFAULT_COLORS.lightScheme.text,
},
},
dark: {
Default: {
backgroundColor: DEFAULT_COLORS.darkScheme.background,
textColor: DEFAULT_COLORS.darkScheme.text,
},
},
}
const DEFAULT_SETTINGS = {
enabled: true,
fetchNews: true,
theme: DEFAULT_THEME,
presets: [],
customThemes: [],
siteList: ['docs.google.com/document'],
siteListEnabled: [],
applyToListedOnly: true,
changeBrowserTheme: false,
syncSettings: true,
syncSitesFixes: false,
automation: '',
automationBehaviour: 'OnOff',
time: {
activation: '18:00',
deactivation: '9:00',
},
location: {
latitude: null,
longitude: null,
},
previewNewDesign: false,
enableForPDF: true,
enableForProtectedPages: false,
enableContextMenus: false,
detectDarkTheme: false,
}
const CONFIG_URLs = {
darkSites: {
local: '../config/dark-sites.config',
},
dynamicThemeFixes: {
local: '../config/dynamic-theme-fixes.config',
},
inversionFixes: {
local: '../config/inversion-fixes.config',
},
staticThemes: {
local: '../config/static-themes.config',
},
colorSchemes: {
local: '../config/color-schemes.drconf',
},
}
const REMOTE_TIMEOUT_MS = getDuration({ seconds: 10 })
class ConfigManager {
constructor() {
this.raw = {
darkSites: null,
dynamicThemeFixes: null,
inversionFixes: null,
staticThemes: null,
colorSchemes: null,
}
this.overrides = {
darkSites: null,
dynamicThemeFixes: null,
inversionFixes: null,
staticThemes: null,
}
}
async loadConfig({ name, local, localURL, remoteURL }) {
let $config
const loadLocal = async () => await readText({ url: localURL })
if (local) {
$config = await loadLocal()
} else {
try {
$config = await readText({
url: `${remoteURL}?nocache=${Date.now()}`,
timeout: REMOTE_TIMEOUT_MS,
})
} catch (err) {
console.error(`${name} remote load error`, err)
$config = await loadLocal()
}
}
return $config
}
async loadColorSchemes({ local }) {
const $config = await this.loadConfig({
name: 'Color Schemes',
local,
localURL: CONFIG_URLs.colorSchemes.local,
remoteURL: CONFIG_URLs.colorSchemes.remote,
})
this.raw.colorSchemes = $config
this.handleColorSchemes()
}
async loadDarkSites({ local }) {
const sites = await this.loadConfig({
name: 'Dark Sites',
local,
localURL: CONFIG_URLs.darkSites.local,
remoteURL: CONFIG_URLs.darkSites.remote,
})
this.raw.darkSites = sites
this.handleDarkSites()
}
async loadDynamicThemeFixes({ local }) {
const fixes = await this.loadConfig({
name: 'Dynamic Theme Fixes',
local,
localURL: CONFIG_URLs.dynamicThemeFixes.local,
remoteURL: CONFIG_URLs.dynamicThemeFixes.remote,
})
this.raw.dynamicThemeFixes = fixes
this.handleDynamicThemeFixes()
}
async loadInversionFixes({ local }) {
const fixes = await this.loadConfig({
name: 'Inversion Fixes',
local,
localURL: CONFIG_URLs.inversionFixes.local,
remoteURL: CONFIG_URLs.inversionFixes.remote,
})
this.raw.inversionFixes = fixes
this.handleInversionFixes()
}
async loadStaticThemes({ local }) {
const themes = await this.loadConfig({
name: 'Static Themes',
local,
localURL: CONFIG_URLs.staticThemes.local,
remoteURL: CONFIG_URLs.staticThemes.remote,
})
this.raw.staticThemes = themes
this.handleStaticThemes()
}
async load(config) {
await Promise.all([
this.loadColorSchemes(config),
this.loadDarkSites(config),
this.loadDynamicThemeFixes(config),
this.loadInversionFixes(config),
this.loadStaticThemes(config),
]).catch((err) => console.error('Fatality', err))
}
handleColorSchemes() {
const $config = this.raw.colorSchemes
const { result, error } = ParseColorSchemeConfig($config)
if (error) {
this.COLOR_SCHEMES_RAW = DEFAULT_COLORSCHEME
return
}
this.COLOR_SCHEMES_RAW = result
}
handleDarkSites() {
const $sites = this.overrides.darkSites || this.raw.darkSites
this.DARK_SITES = parseArray($sites)
}
handleDynamicThemeFixes() {
const $fixes =
this.overrides.dynamicThemeFixes || this.raw.dynamicThemeFixes
this.DYNAMIC_THEME_FIXES_INDEX = indexSitesFixesConfig($fixes)
this.DYNAMIC_THEME_FIXES_RAW = $fixes
}
handleInversionFixes() {
const $fixes = this.overrides.inversionFixes || this.raw.inversionFixes
this.INVERSION_FIXES_INDEX = indexSitesFixesConfig($fixes)
this.INVERSION_FIXES_RAW = $fixes
}
handleStaticThemes() {
const $themes = this.overrides.staticThemes || this.raw.staticThemes
this.STATIC_THEMES_INDEX = indexSitesFixesConfig($themes)
this.STATIC_THEMES_RAW = $themes
}
}
function isArrayLike(items) {
return items.length != null
}
function forEach(items, iterator) {
if (isArrayLike(items)) {
for (let i = 0, len = items.length; i < len; i++) {
iterator(items[i])
}
} else {
for (const item of items) {
iterator(item)
}
}
}
function push(array, addition) {
forEach(addition, (a) => array.push(a))
}
function formatSitesFixesConfig(fixes, options) {
const lines = []
fixes.forEach((fix, i) => {
push(lines, fix.url)
options.props.forEach((prop) => {
const command = options.getPropCommandName(prop)
const value = fix[prop]
if (options.shouldIgnoreProp(prop, value)) {
return
}
lines.push('')
lines.push(command)
const formattedValue = options.formatPropValue(prop, value)
if (formattedValue) {
lines.push(formattedValue)
}
})
if (i < fixes.length - 1) {
lines.push('')
lines.push('='.repeat(32))
lines.push('')
}
})
lines.push('')
return lines.join('\n')
}
function scale(x, inLow, inHigh, outLow, outHigh) {
return ((x - inLow) * (outHigh - outLow)) / (inHigh - inLow) + outLow
}
function clamp(x, min, max) {
return Math.min(max, Math.max(min, x))
}
function multiplyMatrices(m1, m2) {
const result = []
for (let i = 0, len = m1.length; i < len; i++) {
result[i] = []
for (let j = 0, len2 = m2[0].length; j < len2; j++) {
let sum = 0
for (let k = 0, len3 = m1[0].length; k < len3; k++) {
sum += m1[i][k] * m2[k][j]
}
result[i][j] = sum
}
}
return result
}
function createFilterMatrix(config) {
let m = Matrix.identity()
if (config.sepia !== 0) {
m = multiplyMatrices(m, Matrix.sepia(config.sepia / 100))
}
if (config.grayscale !== 0) {
m = multiplyMatrices(m, Matrix.grayscale(config.grayscale / 100))
}
if (config.contrast !== 100) {
m = multiplyMatrices(m, Matrix.contrast(config.contrast / 100))
}
if (config.brightness !== 100) {
m = multiplyMatrices(m, Matrix.brightness(config.brightness / 100))
}
if (config.mode === 1) {
m = multiplyMatrices(m, Matrix.invertNHue())
}
return m
}
function applyColorMatrix([r, g, b], matrix) {
const rgb = [[r / 255], [g / 255], [b / 255], [1], [1]]
const result = multiplyMatrices(matrix, rgb)
return [0, 1, 2].map((i) => clamp(Math.round(result[i][0] * 255), 0, 255))
}
const Matrix = {
identity() {
return [
[1, 0, 0, 0, 0],
[0, 1, 0, 0, 0],
[0, 0, 1, 0, 0],
[0, 0, 0, 1, 0],
[0, 0, 0, 0, 1],
]
},
invertNHue() {
return [
[0.333, -0.667, -0.667, 0, 1],
[-0.667, 0.333, -0.667, 0, 1],
[-0.667, -0.667, 0.333, 0, 1],
[0, 0, 0, 1, 0],
[0, 0, 0, 0, 1],
]
},
brightness(v) {
return [
[v, 0, 0, 0, 0],
[0, v, 0, 0, 0],
[0, 0, v, 0, 0],
[0, 0, 0, 1, 0],
[0, 0, 0, 0, 1],
]
},
contrast(v) {
const t = (1 - v) / 2
return [
[v, 0, 0, 0, t],
[0, v, 0, 0, t],
[0, 0, v, 0, t],
[0, 0, 0, 1, 0],
[0, 0, 0, 0, 1],
]
},
sepia(v) {
return [
[
0.393 + 0.607 * (1 - v),
0.769 - 0.769 * (1 - v),
0.189 - 0.189 * (1 - v),
0,
0,
],
[
0.349 - 0.349 * (1 - v),
0.686 + 0.314 * (1 - v),
0.168 - 0.168 * (1 - v),
0,
0,
],
[
0.272 - 0.272 * (1 - v),
0.534 - 0.534 * (1 - v),
0.131 + 0.869 * (1 - v),
0,
0,
],
[0, 0, 0, 1, 0],
[0, 0, 0, 0, 1],
]
},
grayscale(v) {
return [
[
0.2126 + 0.7874 * (1 - v),
0.7152 - 0.7152 * (1 - v),
0.0722 - 0.0722 * (1 - v),
0,
0,
],
[
0.2126 - 0.2126 * (1 - v),
0.7152 + 0.2848 * (1 - v),
0.0722 - 0.0722 * (1 - v),
0,
0,
],
[
0.2126 - 0.2126 * (1 - v),
0.7152 - 0.7152 * (1 - v),
0.0722 + 0.9278 * (1 - v),
0,
0,
],
[0, 0, 0, 1, 0],
[0, 0, 0, 0, 1],
]
},
}
function createTextStyle(config) {
const lines = []
lines.push(
'*:not(pre, pre *, code, .far, .fa, .glyphicon, [class*="vjs-"], .fab, .fa-github, .fas, .material-icons, .icofont, .typcn, mu, [class*="mu-"], .glyphicon, .icon) {'
)
if (config.useFont && config.fontFamily) {
lines.push(` font-family: ${config.fontFamily} !important;`)
}
if (config.textStroke > 0) {
lines.push(` -webkit-text-stroke: ${config.textStroke}px !important;`)
lines.push(` text-stroke: ${config.textStroke}px !important;`)
}
lines.push('}')
return lines.join('\n')
}
var FilterMode
;(function (FilterMode) {
FilterMode[(FilterMode['light'] = 0)] = 'light'
FilterMode[(FilterMode['dark'] = 1)] = 'dark'
})(FilterMode || (FilterMode = {}))
function hasPatchForChromiumIssue501582() {
return Boolean(
isChromium && compareChromeVersions(chromiumVersion, '81.0.4035.0') >= 0
)
}
function createCSSFilterStyleSheet(config, url, frameURL, fixes, index) {
const filterValue = getCSSFilterValue(config)
const reverseFilterValue = 'invert(100%) hue-rotate(180deg)'
return cssFilterStyleSheetTemplate(
filterValue,
reverseFilterValue,
config,
url,
frameURL,
fixes,
index
)
}
function cssFilterStyleSheetTemplate(
filterValue,
reverseFilterValue,
config,
url,
frameURL,
fixes,
index
) {
const fix = getInversionFixesFor(frameURL || url, fixes, index)
const lines = []
lines.push('@media screen {')
if (filterValue && !frameURL) {
lines.push('')
lines.push('/* Leading rule */')
lines.push(createLeadingRule(filterValue))
}
if (config.mode === FilterMode.dark) {
lines.push('')
lines.push('/* Reverse rule */')
lines.push(createReverseRule(reverseFilterValue, fix))
}
if (config.useFont || config.textStroke > 0) {
lines.push('')
lines.push('/* Font */')
lines.push(createTextStyle(config))
}
lines.push('')
lines.push('/* Text contrast */')
lines.push('html {')
lines.push(' text-shadow: 0 0 0 !important;')
lines.push('}')
lines.push('')
lines.push('/* Full screen */')
;[':-webkit-full-screen', ':-moz-full-screen', ':fullscreen'].forEach(
(fullScreen) => {
lines.push(`${fullScreen}, ${fullScreen} * {`)
lines.push(' -webkit-filter: none !important;')
lines.push(' filter: none !important;')
lines.push('}')
}
)
if (!frameURL) {
const light = [255, 255, 255]
const bgColor =
!hasPatchForChromiumIssue501582() && config.mode === FilterMode.dark
? applyColorMatrix(light, createFilterMatrix(config)).map(Math.round)
: light
lines.push('')
lines.push('/* Page background */')
lines.push('html {')
lines.push(` background: rgb(${bgColor.join(',')}) !important;`)
lines.push('}')
}
if (fix.css && fix.css.length > 0 && config.mode === FilterMode.dark) {
lines.push('')
lines.push('/* Custom rules */')
lines.push(fix.css)
}
lines.push('')
lines.push('}')
return lines.join('\n')
}
function getCSSFilterValue(config) {
const filters = []
if (config.mode === FilterMode.dark) {
filters.push('invert(100%) hue-rotate(180deg)')
}
if (config.brightness !== 100) {
filters.push(`brightness(${config.brightness}%)`)
}
if (config.contrast !== 100) {
filters.push(`contrast(${config.contrast}%)`)
}
if (config.grayscale !== 0) {
filters.push(`grayscale(${config.grayscale}%)`)
}
if (config.sepia !== 0) {
filters.push(`sepia(${config.sepia}%)`)
}
if (filters.length === 0) {
return null
}
return filters.join(' ')
}
function createLeadingRule(filterValue) {
return [
'html {',
` -webkit-filter: ${filterValue} !important;`,
` filter: ${filterValue} !important;`,
'}',
].join('\n')
}
function joinSelectors(selectors) {
return selectors.map((s) => s.replace(/\,$/, '')).join(',\n')
}
function createReverseRule(reverseFilterValue, fix) {
const lines = []
if (fix.invert.length > 0) {
lines.push(`${joinSelectors(fix.invert)} {`)
lines.push(` -webkit-filter: ${reverseFilterValue} !important;`)
lines.push(` filter: ${reverseFilterValue} !important;`)
lines.push('}')
}
if (fix.noinvert.length > 0) {
lines.push(`${joinSelectors(fix.noinvert)} {`)
lines.push(' -webkit-filter: none !important;')
lines.push(' filter: none !important;')
lines.push('}')
}
if (fix.removebg.length > 0) {
lines.push(`${joinSelectors(fix.removebg)} {`)
lines.push(' background: white !important;')
lines.push('}')
}
return lines.join('\n')
}
function getInversionFixesFor(url, fixes, index) {
const inversionFixes = getSitesFixesFor(url, fixes, index, {
commands: Object.keys(inversionFixesCommands),
getCommandPropName: (command) => inversionFixesCommands[command],
parseCommandValue: (command, value) => {
if (command === 'CSS') {
return value.trim()
}
return parseArray(value)
},
})
const common = {
url: inversionFixes[0].url,
invert: inversionFixes[0].invert || [],
noinvert: inversionFixes[0].noinvert || [],
removebg: inversionFixes[0].removebg || [],
css: inversionFixes[0].css || '',
}
if (url) {
const matches = inversionFixes
.slice(1)
.filter((s) => isURLInList(url, s.url))
.sort((a, b) => b.url[0].length - a.url[0].length)
if (matches.length > 0) {
const found = matches[0]
return {
url: found.url,
invert: common.invert.concat(found.invert || []),
noinvert: common.noinvert.concat(found.noinvert || []),
removebg: common.removebg.concat(found.removebg || []),
css: [common.css, found.css].filter((s) => s).join('\n'),
}
}
}
return common
}
const inversionFixesCommands = {
INVERT: 'invert',
'NO INVERT': 'noinvert',
'REMOVE BG': 'removebg',
CSS: 'css',
}
function parseInversionFixes(text) {
return parseSitesFixesConfig(text, {
commands: Object.keys(inversionFixesCommands),
getCommandPropName: (command) => inversionFixesCommands[command],
parseCommandValue: (command, value) => {
if (command === 'CSS') {
return value.trim()
}
return parseArray(value)
},
})
}
function formatInversionFixes(inversionFixes) {
const fixes = inversionFixes
.slice()
.sort((a, b) => compareURLPatterns(a.url[0], b.url[0]))
return formatSitesFixesConfig(fixes, {
props: Object.values(inversionFixesCommands),
getPropCommandName: (prop) =>
Object.entries(inversionFixesCommands).find(([, p]) => p === prop)[0],
formatPropValue: (prop, value) => {
if (prop === 'css') {
return value.trim().replace(/\n+/g, '\n')
}
return formatArray(value).trim()
},
shouldIgnoreProp: (prop, value) => {
if (prop === 'css') {
return !value
}
return !(Array.isArray(value) && value.length > 0)
},
})
}
const dynamicThemeFixesCommands = {
INVERT: 'invert',
CSS: 'css',
'IGNORE INLINE STYLE': 'ignoreInlineStyle',
'IGNORE IMAGE ANALYSIS': 'ignoreImageAnalysis',
}
function parseDynamicThemeFixes(text) {
return parseSitesFixesConfig(text, {
commands: Object.keys(dynamicThemeFixesCommands),
getCommandPropName: (command) => dynamicThemeFixesCommands[command],
parseCommandValue: (command, value) => {
if (command === 'CSS') {
return value.trim()
}
return parseArray(value)
},
})
}
function formatDynamicThemeFixes(dynamicThemeFixes) {
const fixes = dynamicThemeFixes
.slice()
.sort((a, b) => compareURLPatterns(a.url[0], b.url[0]))
return formatSitesFixesConfig(fixes, {
props: Object.values(dynamicThemeFixesCommands),
getPropCommandName: (prop) =>
Object.entries(dynamicThemeFixesCommands).find(
([, p]) => p === prop
)[0],
formatPropValue: (prop, value) => {
if (prop === 'css') {
return value.trim().replace(/\n+/g, '\n')
}
return formatArray(value).trim()
},
shouldIgnoreProp: (prop, value) => {
if (prop === 'css') {
return !value
}
return !(Array.isArray(value) && value.length > 0)
},
})
}
function getDynamicThemeFixesFor(url, frameURL, text, index, enabledForPDF) {
const fixes = getSitesFixesFor(frameURL || url, text, index, {
commands: Object.keys(dynamicThemeFixesCommands),
getCommandPropName: (command) => dynamicThemeFixesCommands[command],
parseCommandValue: (command, value) => {
if (command === 'CSS') {
return value.trim()
}
return parseArray(value)
},
})
if (fixes.length === 0 || fixes[0].url[0] !== '*') {
return null
}
const genericFix = fixes[0]
const common = {
url: genericFix.url,
invert: genericFix.invert || [],
css: genericFix.css || '',
ignoreInlineStyle: genericFix.ignoreInlineStyle || [],
ignoreImageAnalysis: genericFix.ignoreImageAnalysis || [],
}
if (enabledForPDF) {
if (isChromium) {
common.css +=
'\nembed[type="application/pdf"][src="about:blank"] { filter: invert(100%) contrast(90%); }'
} else {
common.css +=
'\nembed[type="application/pdf"] { filter: invert(100%) contrast(90%); }'
}
}
const sortedBySpecificity = fixes
.slice(1)
.map((theme) => {
return {
specificity: isURLInList(frameURL || url, theme.url)
? theme.url[0].length
: 0,
theme,
}
})
.filter(({ specificity }) => specificity > 0)
.sort((a, b) => b.specificity - a.specificity)
if (sortedBySpecificity.length === 0) {
return common
}
const match = sortedBySpecificity[0].theme
return {
url: match.url,
invert: common.invert.concat(match.invert || []),
css: [common.css, match.css].filter((s) => s).join('\n'),
ignoreInlineStyle: common.ignoreInlineStyle.concat(
match.ignoreInlineStyle || []
),
ignoreImageAnalysis: common.ignoreImageAnalysis.concat(
match.ignoreImageAnalysis || []
),
}
}
const darkTheme = {
neutralBg: [16, 20, 23],
neutralText: [167, 158, 139],
redBg: [64, 12, 32],
redText: [247, 142, 102],
greenBg: [32, 64, 48],
greenText: [128, 204, 148],
blueBg: [32, 48, 64],
blueText: [128, 182, 204],
fadeBg: [16, 20, 23, 0.5],
fadeText: [167, 158, 139, 0.5],
}
const lightTheme = {
neutralBg: [255, 242, 228],
neutralText: [0, 0, 0],
redBg: [255, 85, 170],
redText: [140, 14, 48],
greenBg: [192, 255, 170],
greenText: [0, 128, 0],
blueBg: [173, 215, 229],
blueText: [28, 16, 171],
fadeBg: [0, 0, 0, 0.5],
fadeText: [0, 0, 0, 0.5],
}
function rgb([r, g, b, a]) {
if (typeof a === 'number') {
return `rgba(${r}, ${g}, ${b}, ${a})`
}
return `rgb(${r}, ${g}, ${b})`
}
function mix(color1, color2, t) {
return color1.map((c, i) => Math.round(c * (1 - t) + color2[i] * t))
}
function createStaticStylesheet(
config,
url,
frameURL,
staticThemes,
staticThemesIndex
) {
const srcTheme = config.mode === 1 ? darkTheme : lightTheme
const theme = Object.entries(srcTheme).reduce((t, [prop, color]) => {
const [r, g, b, a] = color
t[prop] = applyColorMatrix(
[r, g, b],
createFilterMatrix({ ...config, mode: 0 })
)
if (a !== undefined) {
t[prop].push(a)
}
return t
}, {})
const commonTheme = getCommonTheme(staticThemes, staticThemesIndex)
const siteTheme = getThemeFor(
frameURL || url,
staticThemes,
staticThemesIndex
)
const lines = []
if (!siteTheme || !siteTheme.noCommon) {
lines.push('/* Common theme */')
lines.push(...ruleGenerators.map((gen) => gen(commonTheme, theme)))
}
if (siteTheme) {
lines.push(`/* Theme for ${siteTheme.url.join(' ')} */`)
lines.push(...ruleGenerators.map((gen) => gen(siteTheme, theme)))
}
if (config.useFont || config.textStroke > 0) {
lines.push('/* Font */')
lines.push(createTextStyle(config))
}
return lines.filter((ln) => ln).join('\n')
}
function createRuleGen(
getSelectors,
generateDeclarations,
modifySelector = (s) => s
) {
return (siteTheme, themeColors) => {
const selectors = getSelectors(siteTheme)
if (selectors == null || selectors.length === 0) {
return null
}
const lines = []
selectors.forEach((s, i) => {
let ln = modifySelector(s)
if (i < selectors.length - 1) {
ln += ','
} else {
ln += ' {'
}
lines.push(ln)
})
const declarations = generateDeclarations(themeColors)
declarations.forEach((d) => lines.push(` ${d} !important;`))
lines.push('}')
return lines.join('\n')
}
}
const mx = {
bg: {
hover: 0.075,
active: 0.1,
},
fg: {
hover: 0.25,
active: 0.5,
},
border: 0.5,
}
const ruleGenerators = [
createRuleGen(
(t) => t.neutralBg,
(t) => [`background-color: ${rgb(t.neutralBg)}`]
),
createRuleGen(
(t) => t.neutralBgActive,
(t) => [`background-color: ${rgb(t.neutralBg)}`]
),
createRuleGen(
(t) => t.neutralBgActive,
(t) => [
`background-color: ${rgb(
mix(t.neutralBg, [255, 255, 255], mx.bg.hover)
)}`,
],
(s) => `${s}:hover`
),
createRuleGen(
(t) => t.neutralBgActive,
(t) => [
`background-color: ${rgb(
mix(t.neutralBg, [255, 255, 255], mx.bg.active)
)}`,
],
(s) => `${s}:active, ${s}:focus`
),
createRuleGen(
(t) => t.neutralText,
(t) => [`color: ${rgb(t.neutralText)}`]
),
createRuleGen(
(t) => t.neutralTextActive,
(t) => [`color: ${rgb(t.neutralText)}`]
),
createRuleGen(
(t) => t.neutralTextActive,
(t) => [
`color: ${rgb(mix(t.neutralText, [255, 255, 255], mx.fg.hover))}`,
],
(s) => `${s}:hover`
),
createRuleGen(
(t) => t.neutralTextActive,
(t) => [
`color: ${rgb(mix(t.neutralText, [255, 255, 255], mx.fg.active))}`,
],
(s) => `${s}:active, ${s}:focus`
),
createRuleGen(
(t) => t.neutralBorder,
(t) => [
`border-color: ${rgb(mix(t.neutralBg, t.neutralText, mx.border))}`,
]
),
createRuleGen(
(t) => t.redBg,
(t) => [`background-color: ${rgb(t.redBg)}`]
),
createRuleGen(
(t) => t.redBgActive,
(t) => [`background-color: ${rgb(t.redBg)}`]
),
createRuleGen(
(t) => t.redBgActive,
(t) => [
`background-color: ${rgb(mix(t.redBg, [255, 0, 64], mx.bg.hover))}`,
],
(s) => `${s}:hover`
),
createRuleGen(
(t) => t.redBgActive,
(t) => [
`background-color: ${rgb(mix(t.redBg, [255, 0, 64], mx.bg.active))}`,
],
(s) => `${s}:active, ${s}:focus`
),
createRuleGen(
(t) => t.redText,
(t) => [`color: ${rgb(t.redText)}`]
),
createRuleGen(
(t) => t.redTextActive,
(t) => [`color: ${rgb(t.redText)}`]
),
createRuleGen(
(t) => t.redTextActive,
(t) => [`color: ${rgb(mix(t.redText, [255, 255, 0], mx.fg.hover))}`],
(s) => `${s}:hover`
),
createRuleGen(
(t) => t.redTextActive,
(t) => [`color: ${rgb(mix(t.redText, [255, 255, 0], mx.fg.active))}`],
(s) => `${s}:active, ${s}:focus`
),
createRuleGen(
(t) => t.redBorder,
(t) => [`border-color: ${rgb(mix(t.redBg, t.redText, mx.border))}`]
),
createRuleGen(
(t) => t.greenBg,
(t) => [`background-color: ${rgb(t.greenBg)}`]
),
createRuleGen(
(t) => t.greenBgActive,
(t) => [`background-color: ${rgb(t.greenBg)}`]
),
createRuleGen(
(t) => t.greenBgActive,
(t) => [
`background-color: ${rgb(
mix(t.greenBg, [128, 255, 182], mx.bg.hover)
)}`,
],
(s) => `${s}:hover`
),
createRuleGen(
(t) => t.greenBgActive,
(t) => [
`background-color: ${rgb(
mix(t.greenBg, [128, 255, 182], mx.bg.active)
)}`,
],
(s) => `${s}:active, ${s}:focus`
),
createRuleGen(
(t) => t.greenText,
(t) => [`color: ${rgb(t.greenText)}`]
),
createRuleGen(
(t) => t.greenTextActive,
(t) => [`color: ${rgb(t.greenText)}`]
),
createRuleGen(
(t) => t.greenTextActive,
(t) => [`color: ${rgb(mix(t.greenText, [182, 255, 224], mx.fg.hover))}`],
(s) => `${s}:hover`
),
createRuleGen(
(t) => t.greenTextActive,
(t) => [`color: ${rgb(mix(t.greenText, [182, 255, 224], mx.fg.active))}`],
(s) => `${s}:active, ${s}:focus`
),
createRuleGen(
(t) => t.greenBorder,
(t) => [`border-color: ${rgb(mix(t.greenBg, t.greenText, mx.border))}`]
),
createRuleGen(
(t) => t.blueBg,
(t) => [`background-color: ${rgb(t.blueBg)}`]
),
createRuleGen(
(t) => t.blueBgActive,
(t) => [`background-color: ${rgb(t.blueBg)}`]
),
createRuleGen(
(t) => t.blueBgActive,
(t) => [
`background-color: ${rgb(mix(t.blueBg, [0, 128, 255], mx.bg.hover))}`,
],
(s) => `${s}:hover`
),
createRuleGen(
(t) => t.blueBgActive,
(t) => [
`background-color: ${rgb(mix(t.blueBg, [0, 128, 255], mx.bg.active))}`,
],
(s) => `${s}:active, ${s}:focus`
),
createRuleGen(
(t) => t.blueText,
(t) => [`color: ${rgb(t.blueText)}`]
),
createRuleGen(
(t) => t.blueTextActive,
(t) => [`color: ${rgb(t.blueText)}`]
),
createRuleGen(
(t) => t.blueTextActive,
(t) => [`color: ${rgb(mix(t.blueText, [182, 224, 255], mx.fg.hover))}`],
(s) => `${s}:hover`
),
createRuleGen(
(t) => t.blueTextActive,
(t) => [`color: ${rgb(mix(t.blueText, [182, 224, 255], mx.fg.active))}`],
(s) => `${s}:active, ${s}:focus`
),
createRuleGen(
(t) => t.blueBorder,
(t) => [`border-color: ${rgb(mix(t.blueBg, t.blueText, mx.border))}`]
),
createRuleGen(
(t) => t.fadeBg,
(t) => [`background-color: ${rgb(t.fadeBg)}`]
),
createRuleGen(
(t) => t.fadeText,
(t) => [`color: ${rgb(t.fadeText)}`]
),
createRuleGen(
(t) => t.transparentBg,
() => ['background-color: transparent']
),
createRuleGen(
(t) => t.noImage,
() => ['background-image: none']
),
createRuleGen(
(t) => t.invert,
() => ['filter: invert(100%) hue-rotate(180deg)']
),
]
const staticThemeCommands = {
'NO COMMON': 'noCommon',
'NEUTRAL BG': 'neutralBg',
'NEUTRAL BG ACTIVE': 'neutralBgActive',
'NEUTRAL TEXT': 'neutralText',
'NEUTRAL TEXT ACTIVE': 'neutralTextActive',
'NEUTRAL BORDER': 'neutralBorder',
'RED BG': 'redBg',
'RED BG ACTIVE': 'redBgActive',
'RED TEXT': 'redText',
'RED TEXT ACTIVE': 'redTextActive',
'RED BORDER': 'redBorder',
'GREEN BG': 'greenBg',
'GREEN BG ACTIVE': 'greenBgActive',
'GREEN TEXT': 'greenText',
'GREEN TEXT ACTIVE': 'greenTextActive',
'GREEN BORDER': 'greenBorder',
'BLUE BG': 'blueBg',
'BLUE BG ACTIVE': 'blueBgActive',
'BLUE TEXT': 'blueText',
'BLUE TEXT ACTIVE': 'blueTextActive',
'BLUE BORDER': 'blueBorder',
'FADE BG': 'fadeBg',
'FADE TEXT': 'fadeText',
'TRANSPARENT BG': 'transparentBg',
'NO IMAGE': 'noImage',
INVERT: 'invert',
}
function parseStaticThemes($themes) {
return parseSitesFixesConfig($themes, {
commands: Object.keys(staticThemeCommands),
getCommandPropName: (command) => staticThemeCommands[command],
parseCommandValue: (command, value) => {
if (command === 'NO COMMON') {
return true
}
return parseArray(value)
},
})
}
function camelCaseToUpperCase(text) {
return text.replace(/([a-z])([A-Z])/g, '$1 $2').toUpperCase()
}
function formatStaticThemes(staticThemes) {
const themes = staticThemes
.slice()
.sort((a, b) => compareURLPatterns(a.url[0], b.url[0]))
return formatSitesFixesConfig(themes, {
props: Object.values(staticThemeCommands),
getPropCommandName: camelCaseToUpperCase,
formatPropValue: (prop, value) => {
if (prop === 'noCommon') {
return ''
}
return formatArray(value).trim()
},
shouldIgnoreProp: (prop, value) => {
if (prop === 'noCommon') {
return !value
}
return !(Array.isArray(value) && value.length > 0)
},
})
}
function getCommonTheme(staticThemes, staticThemesIndex) {
const length = parseInt(staticThemesIndex.offsets.substring(4, 4 + 3), 36)
const staticThemeText = staticThemes.substring(0, length)
return parseStaticThemes(staticThemeText)[0]
}
function getThemeFor(url, staticThemes, staticThemesIndex) {
const themes = getSitesFixesFor(url, staticThemes, staticThemesIndex, {
commands: Object.keys(staticThemeCommands),
getCommandPropName: (command) => staticThemeCommands[command],
parseCommandValue: (command, value) => {
if (command === 'NO COMMON') {
return true
}
return parseArray(value)
},
})
const sortedBySpecificity = themes
.slice(1)
.map((theme) => {
return {
specificity: isURLInList(url, theme.url) ? theme.url[0].length : 0,
theme,
}
})
.filter(({ specificity }) => specificity > 0)
.sort((a, b) => b.specificity - a.specificity)
if (sortedBySpecificity.length === 0) {
return null
}
return sortedBySpecificity[0].theme
}
class PersistentStorageWrapper {
constructor() {
this.cache = {}
this.dataIsMigrated = false
}
setDataIsMigratedForTesting(value) {}
async migrateFromLocalStorage() {
if (typeof localStorage === 'undefined') {
this.dataIsMigrated = true
return
}
return new Promise((resolve) => {
chrome.storage.local.get(
[DevTools.KEY_DYNAMIC, DevTools.KEY_FILTER, DevTools.KEY_STATIC],
(data) => {
if (chrome.runtime.lastError) {
console.error(
'DevTools failed to migrate data',
chrome.runtime.lastError
)
resolve()
}
if (
data[DevTools.KEY_DYNAMIC] ||
data[DevTools.KEY_FILTER] ||
data[DevTools.KEY_STATIC]
) {
this.dataIsMigrated = true
this.cache = data
resolve()
return
}
this.cache = {
[DevTools.KEY_DYNAMIC]: localStorage.getItem(
DevTools.KEY_DYNAMIC
),
[DevTools.KEY_FILTER]: localStorage.getItem(DevTools.KEY_FILTER),
[DevTools.KEY_STATIC]: localStorage.getItem(DevTools.KEY_STATIC),
}
chrome.storage.local.set(this.cache, () => {
if (chrome.runtime.lastError) {
console.error(
'DevTools failed to migrate data',
chrome.runtime.lastError
)
resolve()
}
this.dataIsMigrated = true
localStorage.removeItem(DevTools.KEY_DYNAMIC)
localStorage.removeItem(DevTools.KEY_FILTER)
localStorage.removeItem(DevTools.KEY_STATIC)
resolve()
})
}
)
})
}
async get(key) {
if (!this.dataIsMigrated) {
await this.migrateFromLocalStorage()
}
if (key in this.cache) {
return this.cache[key]
}
return new Promise((resolve) => {
chrome.storage.local.get(key, (result) => {
if (key in this.cache) {
resolve(this.cache[key])
return
}
if (chrome.runtime.lastError) {
console.error(
'Failed to query DevTools data',
chrome.runtime.lastError
)
resolve(null)
return
}
this.cache[key] = result.key
resolve(result.key)
})
})
}
set(key, value) {
this.cache[key] = value
chrome.storage.local.set({ [key]: value }, () => {
if (chrome.runtime.lastError) {
console.error(
'Failed to write DevTools data',
chrome.runtime.lastError
)
}
})
}
remove(key) {
this.cache[key] = undefined
chrome.storage.local.remove(key, () => {
if (chrome.runtime.lastError) {
console.error(
'Failed to delete DevTools data',
chrome.runtime.lastError
)
}
})
}
async has(key) {
return Boolean(await this.get(key))
}
}
class LocalStorageWrapper {
setDataIsMigratedForTesting() {}
async get(key) {
try {
return localStorage.getItem(key)
} catch (err) {
console.error(err)
return null
}
}
set(key, value) {
try {
localStorage.setItem(key, value)
} catch (err) {
console.error(err)
}
}
remove(key) {
try {
localStorage.removeItem(key)
} catch (err) {
console.error(err)
}
}
async has(key) {
try {
return localStorage.getItem(key) != null
} catch (err) {
console.error(err)
return false
}
}
}
class TempStorage {
constructor() {
this.map = new Map()
}
setDataIsMigratedForTesting() {}
async get(key) {
return this.map.get(key)
}
set(key, value) {
this.map.set(key, value)
}
remove(key) {
this.map.delete(key)
}
async has(key) {
return this.map.has(key)
}
}
class DevTools {
constructor(config, onChange) {
if (
typeof chrome.storage.local !== 'undefined' &&
chrome.storage.local !== null &&
!isFirefox
) {
this.store = new PersistentStorageWrapper()
} else if (typeof localStorage !== 'undefined' && localStorage != null) {
this.store = new LocalStorageWrapper()
} else {
this.store = new TempStorage()
}
this.config = config
this.loadConfigOverrides()
this.onChange = onChange
}
setDataIsMigratedForTesting(value) {
this.store.setDataIsMigratedForTesting(value)
}
async loadConfigOverrides() {
this.config.overrides.dynamicThemeFixes =
(await this.getSavedDynamicThemeFixes()) || null
this.config.overrides.inversionFixes =
(await this.getSavedInversionFixes()) || null
this.config.overrides.staticThemes =
(await this.getSavedStaticThemes()) || null
}
async getSavedDynamicThemeFixes() {
return this.store.get(DevTools.KEY_DYNAMIC)
}
saveDynamicThemeFixes(text) {
this.store.set(DevTools.KEY_DYNAMIC, text)
}
async hasCustomDynamicThemeFixes() {
return this.store.has(DevTools.KEY_DYNAMIC)
}
async getDynamicThemeFixesText() {
const $fixes = await this.getSavedDynamicThemeFixes()
const fixes = $fixes
? parseDynamicThemeFixes($fixes)
: parseDynamicThemeFixes(this.config.DYNAMIC_THEME_FIXES_RAW)
return formatDynamicThemeFixes(fixes)
}
resetDynamicThemeFixes() {
this.store.remove(DevTools.KEY_DYNAMIC)
this.config.overrides.dynamicThemeFixes = null
this.config.handleDynamicThemeFixes()
this.onChange()
}
applyDynamicThemeFixes(text) {
try {
const formatted = formatDynamicThemeFixes(parseDynamicThemeFixes(text))
this.config.overrides.dynamicThemeFixes = formatted
this.config.handleDynamicThemeFixes()
this.saveDynamicThemeFixes(formatted)
this.onChange()
return null
} catch (err) {
return err
}
}
async getSavedInversionFixes() {
return this.store.get(DevTools.KEY_FILTER)
}
saveInversionFixes(text) {
this.store.set(DevTools.KEY_FILTER, text)
}
async hasCustomFilterFixes() {
return this.store.has(DevTools.KEY_FILTER)
}
async getInversionFixesText() {
const $fixes = await this.getSavedInversionFixes()
const fixes = $fixes
? parseInversionFixes($fixes)
: parseInversionFixes(this.config.INVERSION_FIXES_RAW)
return formatInversionFixes(fixes)
}
resetInversionFixes() {
this.store.remove(DevTools.KEY_FILTER)
this.config.overrides.inversionFixes = null
this.config.handleInversionFixes()
this.onChange()
}
applyInversionFixes(text) {
try {
const formatted = formatInversionFixes(parseInversionFixes(text))
this.config.overrides.inversionFixes = formatted
this.config.handleInversionFixes()
this.saveInversionFixes(formatted)
this.onChange()
return null
} catch (err) {
return err
}
}
async getSavedStaticThemes() {
return this.store.get(DevTools.KEY_STATIC)
}
saveStaticThemes(text) {
this.store.set(DevTools.KEY_STATIC, text)
}
async hasCustomStaticFixes() {
return this.store.has(DevTools.KEY_STATIC)
}
async getStaticThemesText() {
const $themes = await this.getSavedStaticThemes()
const themes = $themes
? parseStaticThemes($themes)
: parseStaticThemes(this.config.STATIC_THEMES_RAW)
return formatStaticThemes(themes)
}
resetStaticThemes() {
this.store.remove(DevTools.KEY_STATIC)
this.config.overrides.staticThemes = null
this.config.handleStaticThemes()
this.onChange()
}
applyStaticThemes(text) {
try {
const formatted = formatStaticThemes(parseStaticThemes(text))
this.config.overrides.staticThemes = formatted
this.config.handleStaticThemes()
this.saveStaticThemes(formatted)
this.onChange()
return null
} catch (err) {
return err
}
}
}
DevTools.KEY_DYNAMIC = 'dev_dynamic_theme_fixes'
DevTools.KEY_FILTER = 'dev_inversion_fixes'
DevTools.KEY_STATIC = 'dev_static_themes'
const ICON_PATHS = {
active_19: '../icons/dm_16.png',
active_38: '../icons/dm_42.png',
inactive_19: '../icons/dm_16.png',
inactive_38: '../icons/dm_42.png',
}
class IconManager {
setActive() {
if (!chrome.action.setIcon || isThunderbird) {
return
}
chrome.action.setIcon({
path: {
19: ICON_PATHS.active_19,
38: ICON_PATHS.active_38,
},
})
}
setInactive() {
if (!chrome.action.setIcon || isThunderbird) {
return
}
chrome.action.setIcon({
path: {
19: ICON_PATHS.inactive_19,
38: ICON_PATHS.inactive_38,
},
})
}
showBadge(text) {
chrome.action.setBadgeBackgroundColor({ color: '#e96c4c' })
chrome.action.setBadgeText({ text })
}
hideBadge() {
chrome.action.setBadgeText({ text: '' })
}
}
const MessageType = {
UI_GET_DATA: 'ui-get-data',
UI_SUBSCRIBE_TO_CHANGES: 'ui-subscribe-to-changes',
UI_UNSUBSCRIBE_FROM_CHANGES: 'ui-unsubscribe-from-changes',
UI_CHANGE_SETTINGS: 'ui-change-settings',
UI_SET_THEME: 'ui-set-theme',
UI_SET_SHORTCUT: 'ui-set-shortcut',
UI_TOGGLE_ACTIVE_TAB: 'ui-toggle-active-tab',
UI_MARK_NEWS_AS_READ: 'ui-mark-news-as-read',
UI_MARK_NEWS_AS_DISPLAYED: 'ui-mark-news-as-displayed',
UI_LOAD_CONFIG: 'ui-load-config',
UI_APPLY_DEV_DYNAMIC_THEME_FIXES: 'ui-apply-dev-dynamic-theme-fixes',
UI_RESET_DEV_DYNAMIC_THEME_FIXES: 'ui-reset-dev-dynamic-theme-fixes',
UI_APPLY_DEV_INVERSION_FIXES: 'ui-apply-dev-inversion-fixes',
UI_RESET_DEV_INVERSION_FIXES: 'ui-reset-dev-inversion-fixes',
UI_APPLY_DEV_STATIC_THEMES: 'ui-apply-dev-static-themes',
UI_RESET_DEV_STATIC_THEMES: 'ui-reset-dev-static-themes',
UI_SAVE_FILE: 'ui-save-file',
UI_REQUEST_EXPORT_CSS: 'ui-request-export-css',
BG_CHANGES: 'bg-changes',
BG_ADD_CSS_FILTER: 'bg-add-css-filter',
BG_ADD_STATIC_THEME: 'bg-add-static-theme',
BG_ADD_SVG_FILTER: 'bg-add-svg-filter',
BG_ADD_DYNAMIC_THEME: 'bg-add-dynamic-theme',
BG_EXPORT_CSS: 'bg-export-css',
BG_UNSUPPORTED_SENDER: 'bg-unsupported-sender',
BG_CLEAN_UP: 'bg-clean-up',
BG_RELOAD: 'bg-reload',
BG_FETCH_RESPONSE: 'bg-fetch-response',
BG_UI_UPDATE: 'bg-ui-update',
BG_CSS_UPDATE: 'bg-css-update',
CS_COLOR_SCHEME_CHANGE: 'cs-color-scheme-change',
CS_FRAME_CONNECT: 'cs-frame-connect',
CS_FRAME_FORGET: 'cs-frame-forget',
CS_FRAME_FREEZE: 'cs-frame-freeze',
CS_FRAME_RESUME: 'cs-frame-resume',
CS_EXPORT_CSS_RESPONSE: 'cs-export-css-response',
CS_FETCH: 'cs-fetch',
CS_DARK_THEME_DETECTED: 'cs-dark-theme-detected',
CS_DARK_THEME_NOT_DETECTED: 'cs-dark-theme-not-detected',
}
class Messenger {
constructor(adapter) {
this.adapter = adapter
this.changeListenerCount = 0
const allowedSenderURL = [
chrome.runtime.getURL('/ui/popup/index.html'),
chrome.runtime.getURL('/ui/devtools/index.html'),
chrome.runtime.getURL('/ui/stylesheet-editor/index.html'),
]
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (allowedSenderURL.includes(sender.url)) {
this.onUIMessage(message, sendResponse)
this.adapter.onPopupOpen()
return [MessageType.UI_GET_DATA].includes(message.type)
}
})
if (isFirefox) {
chrome.runtime.onConnect.addListener((port) => {
let promise
switch (port.name) {
case MessageType.UI_GET_DATA:
promise = this.adapter.collect()
break
case MessageType.UI_APPLY_DEV_DYNAMIC_THEME_FIXES:
case MessageType.UI_APPLY_DEV_INVERSION_FIXES:
case MessageType.UI_APPLY_DEV_STATIC_THEMES:
promise = new Promise((resolve, reject) => {
port.onMessage.addListener((message) => {
const { data } = message
let error
switch (port.name) {
case MessageType.UI_APPLY_DEV_DYNAMIC_THEME_FIXES:
error = this.adapter.applyDevDynamicThemeFixes(data)
break
case MessageType.UI_APPLY_DEV_INVERSION_FIXES:
error = this.adapter.applyDevInversionFixes(data)
break
case MessageType.UI_APPLY_DEV_STATIC_THEMES:
error = this.adapter.applyDevStaticThemes(data)
break
default:
throw new Error(`Unknown port name: ${port.name}`)
}
if (error) {
reject(error)
} else {
resolve(null)
}
})
})
break
default:
return
}
promise
.then((data) => port.postMessage({ data }))
.catch((error) => port.postMessage({ error }))
})
}
}
onUIMessage({ type, data }, sendResponse) {
switch (type) {
case MessageType.UI_GET_DATA: {
this.adapter.collect().then((data) => sendResponse({ data }))
break
}
case MessageType.UI_SUBSCRIBE_TO_CHANGES: {
this.changeListenerCount++
break
}
case MessageType.UI_UNSUBSCRIBE_FROM_CHANGES: {
this.changeListenerCount--
break
}
case MessageType.UI_CHANGE_SETTINGS: {
this.adapter.changeSettings(data)
break
}
case MessageType.UI_SET_THEME: {
this.adapter.setTheme(data)
break
}
case MessageType.UI_SET_SHORTCUT: {
this.adapter.setShortcut(data)
break
}
case MessageType.UI_TOGGLE_ACTIVE_TAB: {
this.adapter.toggleActiveTab()
break
}
case MessageType.UI_MARK_NEWS_AS_READ: {
this.adapter.markNewsAsRead(data)
break
}
case MessageType.UI_MARK_NEWS_AS_DISPLAYED: {
this.adapter.markNewsAsDisplayed(data)
break
}
case MessageType.UI_LOAD_CONFIG: {
this.adapter.loadConfig(data)
break
}
case MessageType.UI_APPLY_DEV_DYNAMIC_THEME_FIXES: {
const error = this.adapter.applyDevDynamicThemeFixes(data)
sendResponse({ error: error ? error.message : null })
break
}
case MessageType.UI_RESET_DEV_DYNAMIC_THEME_FIXES: {
this.adapter.resetDevDynamicThemeFixes()
break
}
case MessageType.UI_APPLY_DEV_INVERSION_FIXES: {
const error = this.adapter.applyDevInversionFixes(data)
sendResponse({ error: error ? error.message : null })
break
}
case MessageType.UI_RESET_DEV_INVERSION_FIXES: {
this.adapter.resetDevInversionFixes()
break
}
case MessageType.UI_APPLY_DEV_STATIC_THEMES: {
const error = this.adapter.applyDevStaticThemes(data)
sendResponse({ error: error ? error.message : null })
break
}
case MessageType.UI_RESET_DEV_STATIC_THEMES: {
this.adapter.resetDevStaticThemes()
break
}
}
}
reportChanges(data) {
if (this.changeListenerCount > 0) {
chrome.runtime.sendMessage({
type: MessageType.BG_CHANGES,
data,
})
}
}
}
function getUILanguage() {
let code
if (
'i18n' in chrome &&
'getUILanguage' in chrome.i18n &&
typeof chrome.i18n.getUILanguage === 'function'
) {
code = chrome.i18n.getUILanguage()
} else {
code = navigator.language.split('-')[0]
}
if (code.endsWith('-mac')) {
return code.substring(0, code.length - 4)
}
return code
}
const helpLocales = [
'be',
'cs',
'de',
'en',
'es',
'fr',
'it',
'nl',
'pt',
'ru',
'tr',
'zh-CN',
'zh-TW',
]
function getHelpURL() {
const locale = getUILanguage()
const matchLocale =
helpLocales.find((hl) => hl === locale) ||
helpLocales.find((hl) => locale.startsWith(hl)) ||
'en'
return 0
}
// function getBlogPostURL(postId) {
// return `${BLOG_URL}${postId}/`;
// }
function canInjectScript(url) {
if (isFirefox) {
return (
url &&
!url.startsWith('about:') &&
!url.startsWith('moz') &&
!url.startsWith('view-source:') &&
!url.startsWith('https://addons.mozilla.org/') &&
!isPDF(url)
)
}
if (isEdge) {
return (
url &&
!url.startsWith('chrome') &&
!url.startsWith('data') &&
!url.startsWith('devtools') &&
!url.startsWith('edge') &&
!url.startsWith('https://chrome.google.com/webstore') &&
!url.startsWith('https://microsoftedge.microsoft.com/addons') &&
!url.startsWith('view-source')
)
}
return (
url &&
!url.startsWith('chrome') &&
!url.startsWith('https://chrome.google.com/webstore') &&
!url.startsWith('data') &&
!url.startsWith('devtools') &&
!url.startsWith('view-source')
)
}
async function readSyncStorage(defaults) {
return new Promise((resolve) => {
chrome.storage.sync.get(null, (sync) => {
if (chrome.runtime.lastError) {
console.error(chrome.runtime.lastError.message)
resolve(null)
return
}
for (const key in sync) {
if (!sync[key]) {
continue
}
const metaKeysCount = sync[key].__meta_split_count
if (!metaKeysCount) {
continue
}
let string = ''
for (let i = 0; i < metaKeysCount; i++) {
string += sync[`${key}_${i.toString(36)}`]
delete sync[`${key}_${i.toString(36)}`]
}
try {
sync[key] = JSON.parse(string)
} catch (error) {
console.error(
`sync[${key}]: Could not parse record from sync storage: ${string}`
)
resolve(null)
return
}
}
sync = {
...defaults,
...sync,
}
resolve(sync)
})
})
}
async function readLocalStorage(defaults) {
return new Promise((resolve) => {
chrome.storage.local.get(defaults, (local) => {
if (chrome.runtime.lastError) {
console.error(chrome.runtime.lastError.message)
resolve(defaults)
return
}
resolve(local)
})
})
}
function prepareSyncStorage(values) {
for (const key in values) {
const value = values[key]
const string = JSON.stringify(value)
const totalLength = string.length + key.length
if (totalLength > chrome.storage.sync.QUOTA_BYTES_PER_ITEM) {
const maxLength =
chrome.storage.sync.QUOTA_BYTES_PER_ITEM - key.length - 1 - 2
const minimalKeysNeeded = Math.ceil(string.length / maxLength)
for (let i = 0; i < minimalKeysNeeded; i++) {
values[`${key}_${i.toString(36)}`] = string.substring(
i * maxLength,
(i + 1) * maxLength
)
}
values[key] = {
__meta_split_count: minimalKeysNeeded,
}
}
}
return values
}
async function writeSyncStorage(values) {
return new Promise(async (resolve, reject) => {
const packaged = prepareSyncStorage(values)
chrome.storage.sync.set(packaged, () => {
if (chrome.runtime.lastError) {
reject(chrome.runtime.lastError)
return
}
resolve()
})
})
}
async function writeLocalStorage(values) {
return new Promise(async (resolve) => {
chrome.storage.local.set(values, () => {
resolve()
})
})
}
async function getCommands() {
return new Promise((resolve) => {
if (!chrome.commands) {
resolve([])
return
}
chrome.commands.getAll((commands) => {
if (commands) {
resolve(commands)
} else {
resolve([])
}
})
})
}
function setShortcut(command, shortcut) {
if (
typeof browser !== 'undefined' &&
browser.commands &&
browser.commands.update
) {
browser.commands.update({ name: command, shortcut })
}
}
class PromiseBarrier {
constructor() {
this.resolves = []
this.rejects = []
this.wasResolved = false
this.wasRejected = false
}
async entry() {
if (this.wasResolved) {
return Promise.resolve(this.resolution)
}
if (this.wasRejected) {
return Promise.reject(this.reason)
}
return new Promise((resolve, reject) => {
this.resolves.push(resolve)
this.rejects.push(reject)
})
}
async resolve(value) {
if (this.wasRejected || this.wasResolved) {
return
}
this.wasResolved = true
this.resolution = value
this.resolves.forEach((resolve) => resolve(value))
this.resolves = null
this.rejects = null
return new Promise((resolve) => setTimeout(() => resolve()))
}
async reject(reason) {
if (this.wasRejected || this.wasResolved) {
return
}
this.wasRejected = true
this.reason = reason
this.rejects.forEach((reject) => reject(reason))
this.resolves = null
this.rejects = null
return new Promise((resolve) => setTimeout(() => resolve()))
}
isPending() {
return !this.wasResolved && !this.wasRejected
}
isFulfilled() {
return this.wasResolved
}
isRejected() {
return this.wasRejected
}
}
function isNonPersistent() {
if (!isChromium) {
return false
}
const background = chrome.runtime.getManifest().background
if ('persistent' in background) {
return background.persistent === false
}
if ('service_worker' in background) {
return true
}
}
var StateManagerState
;(function (StateManagerState) {
StateManagerState[(StateManagerState['INITIAL'] = 0)] = 'INITIAL'
StateManagerState[(StateManagerState['DISABLED'] = 1)] = 'DISABLED'
StateManagerState[(StateManagerState['LOADING'] = 2)] = 'LOADING'
StateManagerState[(StateManagerState['READY'] = 3)] = 'READY'
StateManagerState[(StateManagerState['SAVING'] = 4)] = 'SAVING'
StateManagerState[(StateManagerState['SAVING_OVERRIDE'] = 5)] =
'SAVING_OVERRIDE'
})(StateManagerState || (StateManagerState = {}))
class StateManager {
constructor(localStorageKey, parent, defaults) {
this.meta = StateManagerState.INITIAL
this.loadStateBarrier = null
if (!isNonPersistent()) {
this.meta = StateManagerState.DISABLED
return
}
this.localStorageKey = localStorageKey
this.parent = parent
this.defaults = defaults
}
collectState() {
const state = {}
for (const key of Object.keys(this.defaults)) {
state[key] = this.parent[key] || this.defaults[key]
}
return state
}
async saveState() {
switch (this.meta) {
case StateManagerState.DISABLED:
return
case StateManagerState.LOADING:
case StateManagerState.INITIAL:
if (this.loadStateBarrier) {
await this.loadStateBarrier.entry()
}
this.meta = StateManagerState.SAVING
break
case StateManagerState.READY:
this.meta = StateManagerState.SAVING
break
case StateManagerState.SAVING:
this.meta = StateManagerState.SAVING_OVERRIDE
return
case StateManagerState.SAVING_OVERRIDE:
return
}
chrome.storage.local.set(
{ [this.localStorageKey]: this.collectState() },
() => {
switch (this.meta) {
case StateManagerState.INITIAL:
case StateManagerState.DISABLED:
case StateManagerState.LOADING:
case StateManagerState.READY:
case StateManagerState.SAVING:
this.meta = StateManagerState.READY
break
case StateManagerState.SAVING_OVERRIDE:
this.meta = StateManagerState.READY
this.saveState()
}
}
)
}
async loadState() {
switch (this.meta) {
case StateManagerState.INITIAL:
this.meta = StateManagerState.LOADING
this.loadStateBarrier = new PromiseBarrier()
return new Promise((resolve) => {
chrome.storage.local.get(this.localStorageKey, (data) => {
this.meta = StateManagerState.READY
if (data[this.localStorageKey]) {
Object.assign(this.parent, data[this.localStorageKey])
} else {
Object.assign(this.parent, this.defaults)
}
this.loadStateBarrier.resolve()
this.loadStateBarrier = null
resolve()
})
})
case StateManagerState.DISABLED:
case StateManagerState.READY:
case StateManagerState.SAVING:
case StateManagerState.SAVING_OVERRIDE:
return
case StateManagerState.LOADING:
return this.loadStateBarrier.entry()
}
}
}
class Newsmaker {
constructor(onUpdate) {
this.alarmListener = (alarm) => {
if (alarm.name === Newsmaker.ALARM_NAME) {
this.updateNews()
}
}
this.stateManager = new StateManager(Newsmaker.LOCAL_STORAGE_KEY, this, {
latest: [],
latestTimestamp: null,
})
this.latest = []
this.latestTimestamp = null
this.onUpdate = onUpdate
}
async getLatest() {
await this.stateManager.loadState()
return this.latest
}
subscribe() {
if (
this.latestTimestamp === null ||
this.latestTimestamp + Newsmaker.UPDATE_INTERVAL < Date.now()
) {
this.updateNews()
}
chrome.alarms.onAlarm.addListener((alarm) => this.alarmListener(alarm))
chrome.alarms.create(Newsmaker.ALARM_NAME, {
periodInMinutes: Newsmaker.UPDATE_INTERVAL,
})
}
unSubscribe() {
chrome.alarms.onAlarm.removeListener(this.alarmListener)
chrome.alarms.clear(Newsmaker.ALARM_NAME)
}
async updateNews() {
const news = await this.getNews()
if (Array.isArray(news)) {
this.latest = news
this.latestTimestamp = Date.now()
this.onUpdate(this.latest)
await this.stateManager.saveState()
}
}
async getReadNews() {
const sync = await readSyncStorage({ readNews: [] })
const local = await readLocalStorage({ readNews: [] })
return Array.from(
new Set([
...(sync ? sync.readNews : []),
...(local ? local.readNews : []),
])
)
}
async getDisplayedNews() {
const sync = await readSyncStorage({ displayedNews: [] })
const local = await readLocalStorage({ displayedNews: [] })
return Array.from(
new Set([
...(sync ? sync.displayedNews : []),
...(local ? local.displayedNews : []),
])
)
}
async getNews() {}
async markAsRead(...ids) {
const readNews = await this.getReadNews()
const results = readNews.slice()
let changed = false
ids.forEach((id) => {
if (readNews.indexOf(id) < 0) {
results.push(id)
changed = true
}
})
if (changed) {
this.latest = this.latest.map((n) => {
const read = this.wasRead(n.id, results)
return { ...n, read }
})
this.onUpdate(this.latest)
const obj = { readNews: results }
await writeLocalStorage(obj)
await writeSyncStorage(obj)
await this.stateManager.saveState()
}
}
async markAsDisplayed(...ids) {
const displayedNews = await this.getDisplayedNews()
const results = displayedNews.slice()
let changed = false
ids.forEach((id) => {
if (displayedNews.indexOf(id) < 0) {
results.push(id)
changed = true
}
})
if (changed) {
this.latest = this.latest.map((n) => {
const displayed = this.wasDisplayed(n.id, results)
return { ...n, displayed }
})
this.onUpdate(this.latest)
const obj = { displayedNews: results }
await writeLocalStorage(obj)
await writeSyncStorage(obj)
await this.stateManager.saveState()
}
}
wasRead(id, readNews) {
return readNews.includes(id)
}
wasDisplayed(id, displayedNews) {
return displayedNews.includes(id)
}
}
Newsmaker.UPDATE_INTERVAL = getDurationInMinutes({ hours: 4 })
Newsmaker.ALARM_NAME = 'newsmaker'
Newsmaker.LOCAL_STORAGE_KEY = 'Newsmaker-state'
async function queryTabs(query) {
return new Promise((resolve) => {
chrome.tabs.query(query, (tabs) => resolve(tabs))
})
}
var DocumentState
;(function (DocumentState) {
DocumentState[(DocumentState['ACTIVE'] = 0)] = 'ACTIVE'
DocumentState[(DocumentState['PASSIVE'] = 1)] = 'PASSIVE'
DocumentState[(DocumentState['HIDDEN'] = 2)] = 'HIDDEN'
DocumentState[(DocumentState['FROZEN'] = 3)] = 'FROZEN'
DocumentState[(DocumentState['TERMINATED'] = 4)] = 'TERMINATED'
DocumentState[(DocumentState['DISCARDED'] = 5)] = 'DISCARDED'
})(DocumentState || (DocumentState = {}))
class TabManager {
constructor({ getConnectionMessage, onColorSchemeChange, getTabMessage }) {
this.fileLoader = null
this.timestamp = null
this.stateManager = new StateManager(TabManager.LOCAL_STORAGE_KEY, this, {
tabs: {},
timestamp: 0,
})
this.tabs = {}
this.getTabMessage = getTabMessage
chrome.runtime.onMessage.addListener(
async (message, sender, sendResponse) => {
function addFrame(tabs, tabId, frameId, senderURL, timestamp) {
let frames
if (tabs[tabId]) {
frames = tabs[tabId]
} else {
frames = {}
tabs[tabId] = frames
}
frames[frameId] = {
url: senderURL,
state: DocumentState.ACTIVE,
timestamp,
}
}
switch (message.type) {
case MessageType.CS_FRAME_CONNECT: {
await this.stateManager.loadState()
const reply = (options) => {
const message = getConnectionMessage(options)
if (message instanceof Promise) {
message.then(
(asyncMessage) =>
asyncMessage &&
chrome.tabs.sendMessage(sender.tab.id, asyncMessage, {
frameId: sender.frameId,
})
)
} else if (message) {
chrome.tabs.sendMessage(sender.tab.id, message, {
frameId: sender.frameId,
})
}
}
const isPanel =
typeof sender === 'undefined' ||
typeof sender.tab === 'undefined' ||
(isOpera && sender.tab.index === -1)
if (isPanel) {
if (isFirefox) {
if (
sender &&
sender.tab &&
typeof sender.tab.id === 'number'
) {
chrome.tabs.sendMessage(
sender.tab.id,
{
type: MessageType.BG_UNSUPPORTED_SENDER,
},
{
frameId:
sender && typeof sender.frameId === 'number'
? sender.frameId
: undefined,
}
)
}
} else {
sendResponse('unsupportedSender')
}
return
}
const tabId = sender.tab.id
const { frameId } = sender
const senderURL = sender.url
const tabURL = sender.tab.url
addFrame(this.tabs, tabId, frameId, senderURL, this.timestamp)
reply({
url: tabURL,
frameURL: frameId === 0 ? null : senderURL,
})
this.stateManager.saveState()
sendResponse({ type: '¯\\_(ツ)_/¯' })
break
}
case MessageType.CS_FRAME_FORGET: {
await this.stateManager.loadState()
if (!sender.tab) {
break
}
const tabId = sender.tab.id
const frameId = sender.frameId
if (frameId === 0) {
delete this.tabs[tabId]
}
if (this.tabs[tabId] && this.tabs[tabId][frameId]) {
delete this.tabs[tabId][frameId]
}
this.stateManager.saveState()
break
}
case MessageType.CS_FRAME_FREEZE:
await this.stateManager.loadState()
const info = this.tabs[sender.tab.id][sender.frameId]
info.state = DocumentState.FROZEN
info.url = null
this.stateManager.saveState()
break
case MessageType.CS_FRAME_RESUME: {
await this.stateManager.loadState()
const tabId = sender.tab.id
const frameId = sender.frameId
const frameURL = sender.url
if (this.tabs[tabId][frameId].timestamp < this.timestamp) {
const message = this.getTabMessage(
this.getTabURL(sender.tab),
frameURL
)
chrome.tabs.sendMessage(tabId, message, { frameId })
}
this.tabs[sender.tab.id][sender.frameId] = {
url: sender.url,
state: DocumentState.ACTIVE,
timestamp: this.timestamp,
}
this.stateManager.saveState()
break
}
case MessageType.CS_DARK_THEME_DETECTED: {
this.tabs[sender.tab.id][sender.frameId].darkThemeDetected = true
break
}
case MessageType.CS_FETCH: {
const id = message.id
const sendResponse = (response) =>
chrome.tabs.sendMessage(
sender.tab.id,
{ type: MessageType.BG_FETCH_RESPONSE, id, ...response },
{ frameId: sender.frameId }
)
if (isThunderbird) {
if (message.data.url.startsWith('chrome://')) {
sendResponse({ data: null })
return
}
}
try {
const { url, responseType, mimeType, origin } = message.data
if (!this.fileLoader) {
this.fileLoader = createFileLoader()
}
const response = await this.fileLoader.get({
url,
responseType,
mimeType,
origin,
})
sendResponse({ data: response })
} catch (err) {
sendResponse({ error: err && err.message ? err.message : err })
}
break
}
case MessageType.CS_COLOR_SCHEME_CHANGE: {
onColorSchemeChange(message.data)
break
}
case MessageType.UI_SAVE_FILE: {
const { content, name } = message.data
const a = document.createElement('a')
a.href = URL.createObjectURL(new Blob([content]))
a.download = name
a.click()
break
}
case MessageType.UI_REQUEST_EXPORT_CSS: {
const activeTab = await this.getActiveTab()
chrome.tabs.sendMessage(
activeTab.id,
{ type: MessageType.BG_EXPORT_CSS },
{ frameId: 0 }
)
break
}
}
}
)
}
getTabURL(tab) {
return tab.url || 'about:blank'
}
async updateContentScript(options) {
;(await queryTabs({}))
.filter(
(tab) => options.runOnProtectedPages || canInjectScript(tab.url)
)
.filter((tab) => !Boolean(this.tabs[tab.id]))
.forEach((tab) => {
if (!tab.discarded) {
if (isMV3) {
chrome.scripting.executeScript({
target: {
tabId: tab.id,
allFrames: true,
},
files: ['/inject/index.js'],
})
} else {
chrome.tabs.executeScript(tab.id, {
runAt: 'document_start',
file: '/inject/index.js',
allFrames: true,
matchAboutBlank: true,
})
}
}
})
}
async registerMailDisplayScript() {
await chrome.messageDisplayScripts.register({
js: [{ file: '/inject/fallback.js' }, { file: '/inject/index.js' }],
})
}
async sendMessage() {
this.timestamp++
;(await queryTabs({}))
.filter((tab) => Boolean(this.tabs[tab.id]))
.forEach((tab) => {
const frames = this.tabs[tab.id]
Object.entries(frames)
.filter(
([, { state }]) =>
state === DocumentState.ACTIVE ||
state === DocumentState.PASSIVE
)
.forEach(([, { url }], frameId) => {
const message = this.getTabMessage(
this.getTabURL(tab),
frameId === 0 ? null : url
)
if (tab.active && frameId === 0) {
chrome.tabs.sendMessage(tab.id, message, { frameId })
} else {
setTimeout(() =>
chrome.tabs.sendMessage(tab.id, message, { frameId })
)
}
if (this.tabs[tab.id][frameId]) {
this.tabs[tab.id][frameId].timestamp = this.timestamp
}
})
})
}
async canAccessActiveTab() {
const tab = await this.getActiveTab()
return Boolean(this.tabs[tab.id])
}
async isActiveTabDarkThemeDetected() {
const tab = await this.getActiveTab()
return (
this.tabs[tab.id] &&
this.tabs[tab.id][0] &&
this.tabs[tab.id][0].darkThemeDetected
)
}
async getActiveTabURL() {
return this.getTabURL(await this.getActiveTab())
}
async getActiveTab() {
let tab = (
await queryTabs({
active: true,
lastFocusedWindow: true,
})
)[0]
const isExtensionPage = (url) =>
url.startsWith('chrome-extension:') || url.startsWith('moz-extension:')
if (!tab || isExtensionPage(tab.url)) {
const tabs = await queryTabs({ active: true })
tab = tabs.find((t) => !isExtensionPage(t.url)) || tab
}
return tab
}
}
TabManager.LOCAL_STORAGE_KEY = 'TabManager-state'
function debounce(delay, fn) {
let timeoutId = null
return (...args) => {
if (timeoutId) {
clearTimeout(timeoutId)
}
timeoutId = setTimeout(() => {
timeoutId = null
fn(...args)
}, delay)
}
}
function isBoolean(x) {
return typeof x === 'boolean'
}
function isPlainObject(x) {
return typeof x === 'object' && x != null && !Array.isArray(x)
}
function isArray(x) {
return Array.isArray(x)
}
function isString(x) {
return typeof x === 'string'
}
function isNonEmptyString(x) {
return x && isString(x)
}
function isNonEmptyArrayOfNonEmptyStrings(x) {
return (
Array.isArray(x) && x.length > 0 && x.every((s) => isNonEmptyString(s))
)
}
function isRegExpMatch(regexp) {
return (x) => {
return isString(x) && x.match(regexp) != null
}
}
const isTime = isRegExpMatch(/^((0?[0-9])|(1[0-9])|(2[0-3])):([0-5][0-9])$/)
function isNumber(x) {
return typeof x === 'number' && !isNaN(x)
}
function isNumberBetween(min, max) {
return (x) => {
return isNumber(x) && x >= min && x <= max
}
}
function isOneOf(...values) {
return (x) => values.includes(x)
}
function hasRequiredProperties(obj, keys) {
return keys.every((key) => obj.hasOwnProperty(key))
}
function createValidator() {
const errors = []
function validateProperty(obj, key, validator, fallback) {
if (!obj.hasOwnProperty(key) || validator(obj[key])) {
return
}
errors.push(`Unexpected value for "${key}": ${JSON.stringify(obj[key])}`)
obj[key] = fallback[key]
}
function validateArray(obj, key, validator) {
if (!obj.hasOwnProperty(key)) {
return
}
const wrongValues = new Set()
const arr = obj[key]
for (let i = 0; i < arr.length; i++) {
if (!validator(arr[i])) {
wrongValues.add(arr[i])
arr.splice(i, 1)
i--
}
}
if (wrongValues.size > 0) {
errors.push(
`Array "${key}" has wrong values: ${Array.from(wrongValues)
.map((v) => JSON.stringify(v))
.join('; ')}`
)
}
}
return { validateProperty, validateArray, errors }
}
function validateSettings(settings) {
if (!isPlainObject(settings)) {
return {
errors: ['Settings are not a plain object'],
settings: DEFAULT_SETTINGS,
}
}
const { validateProperty, validateArray, errors } = createValidator()
const isValidPresetTheme = (theme) => {
if (!isPlainObject(theme)) {
return false
}
const { errors: themeErrors } = validateTheme(theme)
return themeErrors.length === 0
}
validateProperty(settings, 'enabled', isBoolean, DEFAULT_SETTINGS)
validateProperty(settings, 'fetchNews', isBoolean, DEFAULT_SETTINGS)
validateProperty(settings, 'theme', isPlainObject, DEFAULT_SETTINGS)
const { errors: themeErrors } = validateTheme(settings.theme)
errors.push(...themeErrors)
validateProperty(settings, 'presets', isArray, DEFAULT_SETTINGS)
validateArray(settings, 'presets', (preset) => {
const presetValidator = createValidator()
if (
!(
isPlainObject(preset) &&
hasRequiredProperties(preset, ['id', 'name', 'urls', 'theme'])
)
) {
return false
}
presetValidator.validateProperty(preset, 'id', isNonEmptyString, preset)
presetValidator.validateProperty(preset, 'name', isNonEmptyString, preset)
presetValidator.validateProperty(
preset,
'urls',
isNonEmptyArrayOfNonEmptyStrings,
preset
)
presetValidator.validateProperty(
preset,
'theme',
isValidPresetTheme,
preset
)
return presetValidator.errors.length === 0
})
validateProperty(settings, 'customThemes', isArray, DEFAULT_SETTINGS)
validateArray(settings, 'customThemes', (custom) => {
if (
!(
isPlainObject(custom) &&
hasRequiredProperties(custom, ['url', 'theme'])
)
) {
return false
}
const presetValidator = createValidator()
presetValidator.validateProperty(
custom,
'url',
isNonEmptyArrayOfNonEmptyStrings,
custom
)
presetValidator.validateProperty(
custom,
'theme',
isValidPresetTheme,
custom
)
return presetValidator.errors.length === 0
})
validateProperty(settings, 'siteList', isArray, DEFAULT_SETTINGS)
validateArray(settings, 'siteList', isNonEmptyString)
validateProperty(settings, 'siteListEnabled', isArray, DEFAULT_SETTINGS)
validateArray(settings, 'siteListEnabled', isNonEmptyString)
validateProperty(settings, 'applyToListedOnly', isBoolean, DEFAULT_SETTINGS)
validateProperty(
settings,
'changeBrowserTheme',
isBoolean,
DEFAULT_SETTINGS
)
validateProperty(settings, 'syncSettings', isBoolean, DEFAULT_SETTINGS)
validateProperty(settings, 'syncSitesFixes', isBoolean, DEFAULT_SETTINGS)
validateProperty(
settings,
'automation',
isOneOf('', 'time', 'system', 'location'),
DEFAULT_SETTINGS
)
validateProperty(
settings,
'automationBehaviour',
isOneOf('OnOff', 'Scheme'),
DEFAULT_SETTINGS
)
validateProperty(
settings,
'time',
(time) => {
if (!isPlainObject(time)) {
return false
}
const timeValidator = createValidator()
timeValidator.validateProperty(time, 'activation', isTime, time)
timeValidator.validateProperty(time, 'deactivation', isTime, time)
return timeValidator.errors.length === 0
},
DEFAULT_SETTINGS
)
validateProperty(
settings,
'location',
(location) => {
if (!isPlainObject(location)) {
return false
}
const locValidator = createValidator()
const isValidLoc = (x) => x === null || isNumber(x)
locValidator.validateProperty(
location,
'latitude',
isValidLoc,
location
)
locValidator.validateProperty(
location,
'longitude',
isValidLoc,
location
)
return locValidator.errors.length === 0
},
DEFAULT_SETTINGS
)
validateProperty(settings, 'previewNewDesign', isBoolean, DEFAULT_SETTINGS)
validateProperty(settings, 'enableForPDF', isBoolean, DEFAULT_SETTINGS)
validateProperty(
settings,
'enableForProtectedPages',
isBoolean,
DEFAULT_SETTINGS
)
validateProperty(
settings,
'enableContextMenus',
isBoolean,
DEFAULT_SETTINGS
)
validateProperty(settings, 'detectDarkTheme', isBoolean, DEFAULT_SETTINGS)
return { errors, settings }
}
function validateTheme(theme) {
if (!isPlainObject(theme)) {
return { errors: ['Theme is not a plain object'], theme: DEFAULT_THEME }
}
const { validateProperty, errors } = createValidator()
validateProperty(theme, 'mode', isOneOf(0, 1), DEFAULT_THEME)
validateProperty(
theme,
'brightness',
isNumberBetween(0, 200),
DEFAULT_THEME
)
validateProperty(theme, 'contrast', isNumberBetween(0, 200), DEFAULT_THEME)
validateProperty(theme, 'grayscale', isNumberBetween(0, 100), DEFAULT_THEME)
validateProperty(theme, 'sepia', isNumberBetween(0, 100), DEFAULT_THEME)
validateProperty(theme, 'useFont', isBoolean, DEFAULT_THEME)
validateProperty(theme, 'fontFamily', isNonEmptyString, DEFAULT_THEME)
validateProperty(theme, 'textStroke', isNumberBetween(0, 1), DEFAULT_THEME)
validateProperty(
theme,
'engine',
isOneOf('dynamicTheme', 'staticTheme', 'cssFilter', 'svgFilter'),
DEFAULT_THEME
)
validateProperty(theme, 'stylesheet', isString, DEFAULT_THEME)
validateProperty(
theme,
'darkSchemeBackgroundColor',
isRegExpMatch(/^#[0-9a-f]{6}$/i),
DEFAULT_THEME
)
validateProperty(
theme,
'darkSchemeTextColor',
isRegExpMatch(/^#[0-9a-f]{6}$/i),
DEFAULT_THEME
)
validateProperty(
theme,
'lightSchemeBackgroundColor',
isRegExpMatch(/^#[0-9a-f]{6}$/i),
DEFAULT_THEME
)
validateProperty(
theme,
'lightSchemeTextColor',
isRegExpMatch(/^#[0-9a-f]{6}$/i),
DEFAULT_THEME
)
validateProperty(
theme,
'scrollbarColor',
(x) => x === '' || isRegExpMatch(/^(auto)|(#[0-9a-f]{6})$/i)(x),
DEFAULT_THEME
)
validateProperty(
theme,
'selectionColor',
isRegExpMatch(/^(auto)|(#[0-9a-f]{6})$/i),
DEFAULT_THEME
)
validateProperty(theme, 'styleSystemControls', isBoolean, DEFAULT_THEME)
validateProperty(theme, 'lightColorScheme', isNonEmptyString, DEFAULT_THEME)
validateProperty(theme, 'darkColorScheme', isNonEmptyString, DEFAULT_THEME)
validateProperty(theme, 'immediateModify', isBoolean, DEFAULT_THEME)
return { errors, theme }
}
const SAVE_TIMEOUT = 1000
class UserStorage {
constructor() {
this.saveSettingsIntoStorage = debounce(SAVE_TIMEOUT, async () => {
if (this.saveStorageBarrier) {
await this.saveStorageBarrier.entry()
return
}
this.saveStorageBarrier = new PromiseBarrier()
const settings = this.settings
if (settings.syncSettings) {
try {
await writeSyncStorage(settings)
} catch (err) {
logWarn(
'Settings synchronization was disabled due to error:',
chrome.runtime.lastError
)
this.set({ syncSettings: false })
await this.saveSyncSetting(false)
await writeLocalStorage(settings)
}
} else {
await writeLocalStorage(settings)
}
this.saveStorageBarrier.resolve()
this.saveStorageBarrier = null
})
this.settings = null
}
async loadSettings() {
this.settings = await this.loadSettingsFromStorage()
}
fillDefaults(settings) {
settings.theme = { ...DEFAULT_THEME, ...settings.theme }
settings.time = { ...DEFAULT_SETTINGS.time, ...settings.time }
settings.presets.forEach((preset) => {
preset.theme = { ...DEFAULT_THEME, ...preset.theme }
})
settings.customThemes.forEach((site) => {
site.theme = { ...DEFAULT_THEME, ...site.theme }
})
}
async loadSettingsFromStorage() {
if (this.loadBarrier) {
return await this.loadBarrier.entry()
}
this.loadBarrier = new PromiseBarrier()
const local = await readLocalStorage(DEFAULT_SETTINGS)
const { errors: localCfgErrors } = validateSettings(local)
localCfgErrors.forEach((err) => logWarn(err))
if (local.syncSettings == null) {
local.syncSettings = DEFAULT_SETTINGS.syncSettings
}
if (!local.syncSettings) {
this.fillDefaults(local)
this.loadBarrier.resolve(local)
return local
}
const $sync = await readSyncStorage(DEFAULT_SETTINGS)
if (!$sync) {
local.syncSettings = false
this.set({ syncSettings: false })
this.saveSyncSetting(false)
this.loadBarrier.resolve(local)
return local
}
const { errors: syncCfgErrors } = validateSettings($sync)
syncCfgErrors.forEach((err) => logWarn(err))
this.fillDefaults($sync)
this.loadBarrier.resolve($sync)
return $sync
}
async saveSettings() {
await this.saveSettingsIntoStorage()
}
async saveSyncSetting(sync) {
const obj = { syncSettings: sync }
await writeLocalStorage(obj)
try {
await writeSyncStorage(obj)
} catch (err) {
logWarn(
'Settings synchronization was disabled due to error:',
chrome.runtime.lastError
)
this.set({ syncSettings: false })
}
}
set($settings) {
if ($settings.siteList) {
if (!Array.isArray($settings.siteList)) {
const list = []
for (const key in $settings.siteList) {
const index = Number(key)
if (!isNaN(index)) {
list[index] = $settings.siteList[key]
}
}
$settings.siteList = list
}
const siteList = $settings.siteList.filter((pattern) => {
let isOK = false
try {
isURLMatched('https://google.com/', pattern)
isURLMatched('[::1]:1337', pattern)
isOK = true
} catch (err) {}
return isOK && pattern !== '/'
})
$settings = { ...$settings, siteList }
}
this.settings = { ...this.settings, ...$settings }
}
}
function hslToRGB({ h, s, l, a = 1 }) {
if (s === 0) {
const [r, b, g] = [l, l, l].map((x) => Math.round(x * 255))
return { r, g, b, a }
}
const c = (1 - Math.abs(2 * l - 1)) * s
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
const m = l - c / 2
const [r, g, b] = (
h < 60
? [c, x, 0]
: h < 120
? [x, c, 0]
: h < 180
? [0, c, x]
: h < 240
? [0, x, c]
: h < 300
? [x, 0, c]
: [c, 0, x]
).map((n) => Math.round((n + m) * 255))
return { r, g, b, a }
}
function rgbToHSL({ r: r255, g: g255, b: b255, a = 1 }) {
const r = r255 / 255
const g = g255 / 255
const b = b255 / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const c = max - min
const l = (max + min) / 2
if (c === 0) {
return { h: 0, s: 0, l, a }
}
let h =
(max === r
? ((g - b) / c) % 6
: max === g
? (b - r) / c + 2
: (r - g) / c + 4) * 60
if (h < 0) {
h += 360
}
const s = c / (1 - Math.abs(2 * l - 1))
return { h, s, l, a }
}
function toFixed(n, digits = 0) {
const fixed = n.toFixed(digits)
if (digits === 0) {
return fixed
}
const dot = fixed.indexOf('.')
if (dot >= 0) {
const zerosMatch = fixed.match(/0+$/)
if (zerosMatch) {
if (zerosMatch.index === dot + 1) {
return fixed.substring(0, dot)
}
return fixed.substring(0, zerosMatch.index)
}
}
return fixed
}
function rgbToString(rgb) {
const { r, g, b, a } = rgb
if (a != null && a < 1) {
return `rgba(${toFixed(r)}, ${toFixed(g)}, ${toFixed(b)}, ${toFixed(
a,
2
)})`
}
return `rgb(${toFixed(r)}, ${toFixed(g)}, ${toFixed(b)})`
}
function rgbToHexString({ r, g, b, a }) {
return `#${(a != null && a < 1 ? [r, g, b, Math.round(a * 255)] : [r, g, b])
.map((x) => {
return `${x < 16 ? '0' : ''}${x.toString(16)}`
})
.join('')}`
}
const rgbMatch = /^rgba?\([^\(\)]+\)$/
const hslMatch = /^hsla?\([^\(\)]+\)$/
const hexMatch = /^#[0-9a-f]+$/i
function parse($color) {
const c = $color.trim().toLowerCase()
if (c.match(rgbMatch)) {
return parseRGB(c)
}
if (c.match(hslMatch)) {
return parseHSL(c)
}
if (c.match(hexMatch)) {
return parseHex(c)
}
if (knownColors.has(c)) {
return getColorByName(c)
}
if (systemColors.has(c)) {
return getSystemColor(c)
}
if ($color === 'transparent') {
return { r: 0, g: 0, b: 0, a: 0 }
}
throw new Error(`Unable to parse ${$color}`)
}
function getNumbers($color) {
const numbers = []
let prevPos = 0
let isMining = false
const startIndex = $color.indexOf('(')
$color = $color.substring(startIndex + 1, $color.length - 1)
for (let i = 0; i < $color.length; i++) {
const c = $color[i]
if ((c >= '0' && c <= '9') || c === '.' || c === '+' || c === '-') {
isMining = true
} else if (isMining && (c === ' ' || c === ',')) {
numbers.push($color.substring(prevPos, i))
isMining = false
prevPos = i + 1
} else if (!isMining) {
prevPos = i + 1
}
}
if (isMining) {
numbers.push($color.substring(prevPos, $color.length))
}
return numbers
}
function getNumbersFromString(str, range, units) {
const raw = getNumbers(str)
const unitsList = Object.entries(units)
const numbers = raw
.map((r) => r.trim())
.map((r, i) => {
let n
const unit = unitsList.find(([u]) => r.endsWith(u))
if (unit) {
n =
(parseFloat(r.substring(0, r.length - unit[0].length)) / unit[1]) *
range[i]
} else {
n = parseFloat(r)
}
if (range[i] > 1) {
return Math.round(n)
}
return n
})
return numbers
}
const rgbRange = [255, 255, 255, 1]
const rgbUnits = { '%': 100 }
function parseRGB($rgb) {
const [r, g, b, a = 1] = getNumbersFromString($rgb, rgbRange, rgbUnits)
return { r, g, b, a }
}
const hslRange = [360, 1, 1, 1]
const hslUnits = { '%': 100, deg: 360, rad: 2 * Math.PI, turn: 1 }
function parseHSL($hsl) {
const [h, s, l, a = 1] = getNumbersFromString($hsl, hslRange, hslUnits)
return hslToRGB({ h, s, l, a })
}
function parseHex($hex) {
const h = $hex.substring(1)
switch (h.length) {
case 3:
case 4: {
const [r, g, b] = [0, 1, 2].map((i) => parseInt(`${h[i]}${h[i]}`, 16))
const a = h.length === 3 ? 1 : parseInt(`${h[3]}${h[3]}`, 16) / 255
return { r, g, b, a }
}
case 6:
case 8: {
const [r, g, b] = [0, 2, 4].map((i) =>
parseInt(h.substring(i, i + 2), 16)
)
const a = h.length === 6 ? 1 : parseInt(h.substring(6, 8), 16) / 255
return { r, g, b, a }
}
}
throw new Error(`Unable to parse ${$hex}`)
}
function getColorByName($color) {
const n = knownColors.get($color)
return {
r: (n >> 16) & 255,
g: (n >> 8) & 255,
b: (n >> 0) & 255,
a: 1,
}
}
function getSystemColor($color) {
const n = systemColors.get($color)
return {
r: (n >> 16) & 255,
g: (n >> 8) & 255,
b: (n >> 0) & 255,
a: 1,
}
}
const knownColors = new Map(
Object.entries({
aliceblue: 0xf0f8ff,
antiquewhite: 0xfaebd7,
aqua: 0x00ffff,
aquamarine: 0x7fffd4,
azure: 0xf0ffff,
beige: 0xf5f5dc,
bisque: 0xffe4c4,
black: 0x000000,
blanchedalmond: 0xffebcd,
blue: 0x0000ff,
blueviolet: 0x8a2be2,
brown: 0xa52a2a,
burlywood: 0xdeb887,
cadetblue: 0x5f9ea0,
chartreuse: 0x7fff00,
chocolate: 0xd2691e,
coral: 0xff7f50,
cornflowerblue: 0x6495ed,
cornsilk: 0xfff8dc,
crimson: 0xdc143c,
cyan: 0x00ffff,
darkblue: 0x00008b,
darkcyan: 0x008b8b,
darkgoldenrod: 0xb8860b,
darkgray: 0xa9a9a9,
darkgrey: 0xa9a9a9,
darkgreen: 0x006400,
darkkhaki: 0xbdb76b,
darkmagenta: 0x8b008b,
darkolivegreen: 0x556b2f,
darkorange: 0xff8c00,
darkorchid: 0x9932cc,
darkred: 0x8b0000,
darksalmon: 0xe9967a,
darkseagreen: 0x8fbc8f,
darkslateblue: 0x483d8b,
darkslategray: 0x2f4f4f,
darkslategrey: 0x2f4f4f,
darkturquoise: 0x00ced1,
darkviolet: 0x9400d3,
deeppink: 0xff1493,
deepskyblue: 0x00bfff,
dimgray: 0x696969,
dimgrey: 0x696969,
dodgerblue: 0x1e90ff,
firebrick: 0xb22222,
floralwhite: 0xfffaf0,
forestgreen: 0x228b22,
fuchsia: 0xff00ff,
gainsboro: 0xdcdcdc,
ghostwhite: 0xf8f8ff,
gold: 0xffd700,
goldenrod: 0xdaa520,
gray: 0x808080,
grey: 0x808080,
green: 0x008000,
greenyellow: 0xadff2f,
honeydew: 0xf0fff0,
hotpink: 0xff69b4,
indianred: 0xcd5c5c,
indigo: 0x4b0082,
ivory: 0xfffff0,
khaki: 0xf0e68c,
lavender: 0xe6e6fa,
lavenderblush: 0xfff0f5,
lawngreen: 0x7cfc00,
lemonchiffon: 0xfffacd,
lightblue: 0xadd8e6,
lightcoral: 0xf08080,
lightcyan: 0xe0ffff,
lightgoldenrodyellow: 0xfafad2,
lightgray: 0xd3d3d3,
lightgrey: 0xd3d3d3,
lightgreen: 0x90ee90,
lightpink: 0xffb6c1,
lightsalmon: 0xffa07a,
lightseagreen: 0x20b2aa,
lightskyblue: 0x87cefa,
lightslategray: 0x778899,
lightslategrey: 0x778899,
lightsteelblue: 0xb0c4de,
lightyellow: 0xffffe0,
lime: 0x00ff00,
limegreen: 0x32cd32,
linen: 0xfaf0e6,
magenta: 0xff00ff,
maroon: 0x800000,
mediumaquamarine: 0x66cdaa,
mediumblue: 0x0000cd,
mediumorchid: 0xba55d3,
mediumpurple: 0x9370db,
mediumseagreen: 0x3cb371,
mediumslateblue: 0x7b68ee,
mediumspringgreen: 0x00fa9a,
mediumturquoise: 0x48d1cc,
mediumvioletred: 0xc71585,
midnightblue: 0x191970,
mintcream: 0xf5fffa,
mistyrose: 0xffe4e1,
moccasin: 0xffe4b5,
navajowhite: 0xffdead,
navy: 0x000080,
oldlace: 0xfdf5e6,
olive: 0x808000,
olivedrab: 0x6b8e23,
orange: 0xffa500,
orangered: 0xff4500,
orchid: 0xda70d6,
palegoldenrod: 0xeee8aa,
palegreen: 0x98fb98,
paleturquoise: 0xafeeee,
palevioletred: 0xdb7093,
papayawhip: 0xffefd5,
peachpuff: 0xffdab9,
peru: 0xcd853f,
pink: 0xffc0cb,
plum: 0xdda0dd,
powderblue: 0xb0e0e6,
purple: 0x800080,
rebeccapurple: 0x663399,
red: 0xff0000,
rosybrown: 0xbc8f8f,
royalblue: 0x4169e1,
saddlebrown: 0x8b4513,
salmon: 0xfa8072,
sandybrown: 0xf4a460,
seagreen: 0x2e8b57,
seashell: 0xfff5ee,
sienna: 0xa0522d,
silver: 0xc0c0c0,
skyblue: 0x87ceeb,
slateblue: 0x6a5acd,
slategray: 0x708090,
slategrey: 0x708090,
snow: 0xfffafa,
springgreen: 0x00ff7f,
steelblue: 0x4682b4,
tan: 0xd2b48c,
teal: 0x008080,
thistle: 0xd8bfd8,
tomato: 0xff6347,
turquoise: 0x40e0d0,
violet: 0xee82ee,
wheat: 0xf5deb3,
white: 0xffffff,
whitesmoke: 0xf5f5f5,
yellow: 0xffff00,
yellowgreen: 0x9acd32,
})
)
const systemColors = new Map(
Object.entries({
ActiveBorder: 0x3b99fc,
ActiveCaption: 0x000000,
AppWorkspace: 0xaaaaaa,
Background: 0x6363ce,
ButtonFace: 0xffffff,
ButtonHighlight: 0xe9e9e9,
ButtonShadow: 0x9fa09f,
ButtonText: 0x000000,
CaptionText: 0x000000,
GrayText: 0x7f7f7f,
Highlight: 0xb2d7ff,
HighlightText: 0x000000,
InactiveBorder: 0xffffff,
InactiveCaption: 0xffffff,
InactiveCaptionText: 0x000000,
InfoBackground: 0xfbfcc5,
InfoText: 0x000000,
Menu: 0xf6f6f6,
MenuText: 0xffffff,
Scrollbar: 0xaaaaaa,
ThreeDDarkShadow: 0x000000,
ThreeDFace: 0xc0c0c0,
ThreeDHighlight: 0xffffff,
ThreeDLightShadow: 0xffffff,
ThreeDShadow: 0x000000,
Window: 0xececec,
WindowFrame: 0xaaaaaa,
WindowText: 0x000000,
'-webkit-focus-ring-color': 0xe59700,
}).map(([key, value]) => [key.toLowerCase(), value])
)
function getBgPole(theme) {
const isDarkScheme = theme.mode === 1
const prop = isDarkScheme
? 'darkSchemeBackgroundColor'
: 'lightSchemeBackgroundColor'
return theme[prop]
}
function getFgPole(theme) {
const isDarkScheme = theme.mode === 1
const prop = isDarkScheme ? 'darkSchemeTextColor' : 'lightSchemeTextColor'
return theme[prop]
}
const colorModificationCache = new Map()
const colorParseCache = new Map()
function parseToHSLWithCache(color) {
if (colorParseCache.has(color)) {
return colorParseCache.get(color)
}
const rgb = parse(color)
const hsl = rgbToHSL(rgb)
colorParseCache.set(color, hsl)
return hsl
}
const rgbCacheKeys = ['r', 'g', 'b', 'a']
const themeCacheKeys = [
'mode',
'brightness',
'contrast',
'grayscale',
'sepia',
'darkSchemeBackgroundColor',
'darkSchemeTextColor',
'lightSchemeBackgroundColor',
'lightSchemeTextColor',
]
function getCacheId(rgb, theme) {
let resultId = ''
rgbCacheKeys.forEach((key) => {
resultId += `${rgb[key]};`
})
themeCacheKeys.forEach((key) => {
resultId += `${theme[key]};`
})
return resultId
}
function modifyColorWithCache(
rgb,
theme,
modifyHSL,
poleColor,
anotherPoleColor
) {
let fnCache
if (colorModificationCache.has(modifyHSL)) {
fnCache = colorModificationCache.get(modifyHSL)
} else {
fnCache = new Map()
colorModificationCache.set(modifyHSL, fnCache)
}
const id = getCacheId(rgb, theme)
if (fnCache.has(id)) {
return fnCache.get(id)
}
const hsl = rgbToHSL(rgb)
const pole = poleColor == null ? null : parseToHSLWithCache(poleColor)
const anotherPole =
anotherPoleColor == null ? null : parseToHSLWithCache(anotherPoleColor)
const modified = modifyHSL(hsl, pole, anotherPole)
const { r, g, b, a } = hslToRGB(modified)
const matrix = createFilterMatrix(theme)
const [rf, gf, bf] = applyColorMatrix([r, g, b], matrix)
const color =
a === 1
? rgbToHexString({ r: rf, g: gf, b: bf })
: rgbToString({ r: rf, g: gf, b: bf, a })
fnCache.set(id, color)
return color
}
function modifyLightSchemeColor(rgb, theme) {
const poleBg = getBgPole(theme)
const poleFg = getFgPole(theme)
return modifyColorWithCache(rgb, theme, modifyLightModeHSL, poleFg, poleBg)
}
function modifyLightModeHSL({ h, s, l, a }, poleFg, poleBg) {
const isDark = l < 0.5
let isNeutral
if (isDark) {
isNeutral = l < 0.2 || s < 0.12
} else {
const isBlue = h > 200 && h < 280
isNeutral = s < 0.24 || (l > 0.8 && isBlue)
}
let hx = h
let sx = l
if (isNeutral) {
if (isDark) {
hx = poleFg.h
sx = poleFg.s
} else {
hx = poleBg.h
sx = poleBg.s
}
}
const lx = scale(l, 0, 1, poleFg.l, poleBg.l)
return { h: hx, s: sx, l: lx, a }
}
const MAX_BG_LIGHTNESS = 0.4
function modifyBgHSL({ h, s, l, a }, pole) {
const isDark = l < 0.5
const isBlue = h > 200 && h < 280
const isNeutral = s < 0.12 || (l > 0.8 && isBlue)
if (isDark) {
const lx = scale(l, 0, 0.5, 0, MAX_BG_LIGHTNESS)
if (isNeutral) {
const hx = pole.h
const sx = pole.s
return { h: hx, s: sx, l: lx, a }
}
return { h, s, l: lx, a }
}
let lx = scale(l, 0.5, 1, MAX_BG_LIGHTNESS, pole.l)
if (isNeutral) {
const hx = pole.h
const sx = pole.s
return { h: hx, s: sx, l: lx, a }
}
let hx = h
const isYellow = h > 60 && h < 180
if (isYellow) {
const isCloserToGreen = h > 120
if (isCloserToGreen) {
hx = scale(h, 120, 180, 135, 180)
} else {
hx = scale(h, 60, 120, 60, 105)
}
}
if (hx > 40 && hx < 80) {
lx *= 0.75
}
return { h: hx, s, l: lx, a }
}
function modifyBackgroundColor(rgb, theme) {
if (theme.mode === 0) {
return modifyLightSchemeColor(rgb, theme)
}
const pole = getBgPole(theme)
return modifyColorWithCache(rgb, { ...theme, mode: 0 }, modifyBgHSL, pole)
}
const MIN_FG_LIGHTNESS = 0.55
function modifyBlueFgHue(hue) {
return scale(hue, 205, 245, 205, 220)
}
function modifyFgHSL({ h, s, l, a }, pole) {
const isLight = l > 0.5
const isNeutral = l < 0.2 || s < 0.24
const isBlue = !isNeutral && h > 205 && h < 245
if (isLight) {
const lx = scale(l, 0.5, 1, MIN_FG_LIGHTNESS, pole.l)
if (isNeutral) {
const hx = pole.h
const sx = pole.s
return { h: hx, s: sx, l: lx, a }
}
let hx = h
if (isBlue) {
hx = modifyBlueFgHue(h)
}
return { h: hx, s, l: lx, a }
}
if (isNeutral) {
const hx = pole.h
const sx = pole.s
const lx = scale(l, 0, 0.5, pole.l, MIN_FG_LIGHTNESS)
return { h: hx, s: sx, l: lx, a }
}
let hx = h
let lx
if (isBlue) {
hx = modifyBlueFgHue(h)
lx = scale(l, 0, 0.5, pole.l, Math.min(1, MIN_FG_LIGHTNESS + 0.05))
} else {
lx = scale(l, 0, 0.5, pole.l, MIN_FG_LIGHTNESS)
}
return { h: hx, s, l: lx, a }
}
function modifyForegroundColor(rgb, theme) {
if (theme.mode === 0) {
return modifyLightSchemeColor(rgb, theme)
}
const pole = getFgPole(theme)
return modifyColorWithCache(rgb, { ...theme, mode: 0 }, modifyFgHSL, pole)
}
function modifyBorderHSL({ h, s, l, a }, poleFg, poleBg) {
const isDark = l < 0.5
const isNeutral = l < 0.2 || s < 0.24
let hx = h
let sx = s
if (isNeutral) {
if (isDark) {
hx = poleFg.h
sx = poleFg.s
} else {
hx = poleBg.h
sx = poleBg.s
}
}
const lx = scale(l, 0, 1, 0.5, 0.2)
return { h: hx, s: sx, l: lx, a }
}
function modifyBorderColor(rgb, theme) {
if (theme.mode === 0) {
return modifyLightSchemeColor(rgb, theme)
}
const poleFg = getFgPole(theme)
const poleBg = getBgPole(theme)
return modifyColorWithCache(
rgb,
{ ...theme, mode: 0 },
modifyBorderHSL,
poleFg,
poleBg
)
}
const themeColorTypes = {
accentcolor: 'bg',
button_background_active: 'text',
button_background_hover: 'text',
frame: 'bg',
icons: 'text',
icons_attention: 'text',
ntp_background: 'bg',
ntp_text: 'text',
popup: 'bg',
popup_border: 'bg',
popup_highlight: 'bg',
popup_highlight_text: 'text',
popup_text: 'text',
sidebar: 'bg',
sidebar_border: 'border',
sidebar_text: 'text',
tab_background_text: 'text',
tab_line: 'bg',
tab_loading: 'bg',
tab_selected: 'bg',
textcolor: 'text',
toolbar: 'bg',
toolbar_bottom_separator: 'border',
toolbar_field: 'bg',
toolbar_field_border: 'border',
toolbar_field_border_focus: 'border',
toolbar_field_focus: 'bg',
toolbar_field_separator: 'border',
toolbar_field_text: 'text',
toolbar_field_text_focus: 'text',
toolbar_text: 'text',
toolbar_top_separator: 'border',
toolbar_vertical_separator: 'border',
}
const $colors = {
accentcolor: '#111111',
frame: '#111111',
ntp_background: 'white',
ntp_text: 'black',
popup: '#cccccc',
popup_text: 'black',
sidebar: '#cccccc',
sidebar_border: '#333',
sidebar_text: 'black',
tab_background_text: 'white',
tab_loading: '#23aeff',
textcolor: 'white',
toolbar: '#707070',
toolbar_field: 'lightgray',
toolbar_field_text: 'black',
}
function setWindowTheme(filter) {
const colors = Object.entries($colors).reduce((obj, [key, value]) => {
const type = themeColorTypes[key]
const modify = {
bg: modifyBackgroundColor,
text: modifyForegroundColor,
border: modifyBorderColor,
}[type]
const rgb = parse(value)
const modified = modify(rgb, filter)
obj[key] = modified
return obj
}, {})
if (
typeof browser !== 'undefined' &&
browser.theme &&
browser.theme.update
) {
browser.theme.update({ colors })
}
}
function resetWindowTheme() {
if (
typeof browser !== 'undefined' &&
browser.theme &&
browser.theme.reset
) {
browser.theme.reset()
}
}
function createSVGFilterStylesheet(config, url, frameURL, fixes, index) {
let filterValue
let reverseFilterValue
if (isFirefox) {
filterValue = getEmbeddedSVGFilterValue(getSVGFilterMatrixValue(config))
reverseFilterValue = getEmbeddedSVGFilterValue(
getSVGReverseFilterMatrixValue()
)
} else {
filterValue = 'url(#dark-reader-filter)'
reverseFilterValue = 'url(#dark-reader-reverse-filter)'
}
return cssFilterStyleSheetTemplate(
filterValue,
reverseFilterValue,
config,
url,
frameURL,
fixes,
index
)
}
function getEmbeddedSVGFilterValue(matrixValue) {
const id = 'dark-reader-filter'
const svg = [
'<svg xmlns="http://www.w3.org/2000/svg">',
`<filter id="${id}" style="color-interpolation-filters: sRGB;">`,
`<feColorMatrix type="matrix" values="${matrixValue}" />`,
'</filter>',
'</svg>',
].join('')
return `url(data:image/svg+xml;base64,${btoa(svg)}#${id})`
}
function toSVGMatrix(matrix) {
return matrix
.slice(0, 4)
.map((m) => m.map((m) => m.toFixed(3)).join(' '))
.join(' ')
}
function getSVGFilterMatrixValue(config) {
return toSVGMatrix(createFilterMatrix(config))
}
function getSVGReverseFilterMatrixValue() {
return toSVGMatrix(Matrix.invertNHue())
}
const matchesMediaQuery = (query) => {
if ('window' in globalThis) {
return Boolean(window.matchMedia(query).matches)
}
return false
}
const matchesDarkTheme = () =>
matchesMediaQuery('(prefers-color-scheme: dark)')
const matchesLightTheme = () =>
matchesMediaQuery('(prefers-color-scheme: light)')
const isColorSchemeSupported = matchesDarkTheme() || matchesLightTheme()
function isSystemDarkModeEnabled() {
if (!isColorSchemeSupported) {
return false
}
return matchesDarkTheme()
}
class Extension {
constructor() {
this.autoState = ''
this.wasEnabledOnLastCheck = null
this.registeredContextMenus = null
this.popupOpeningListener = null
this.wasLastColorSchemeDark = null
this.startBarrier = null
this.stateManager = null
this.alarmListener = (alarm) => {
if (alarm.name === Extension.ALARM_NAME) {
this.callWhenSettingsLoaded(() => {
this.handleAutomationCheck()
})
}
}
this.onCommandInternal = async (command, frameURL) => {
if (this.startBarrier.isPending()) {
await this.startBarrier.entry()
}
this.stateManager.loadState()
switch (command) {
case 'toggle':
this.changeSettings({
enabled: !this.isExtensionSwitchedOn(),
automation: '',
})
break
case 'addSite':
const url = frameURL || (await this.tabs.getActiveTabURL())
if (isPDF(url)) {
this.changeSettings({
enableForPDF: !this.user.settings.enableForPDF,
})
} else {
this.toggleActiveTab()
}
break
case 'switchEngine': {
const engines = Object.values(ThemeEngines)
const index = engines.indexOf(this.user.settings.theme.engine)
const next = engines[(index + 1) % engines.length]
this.setTheme({ engine: next })
break
}
}
}
this.onCommand = debounce(75, this.onCommandInternal)
this.onColorSchemeChange = ({ isDark }) => {
if (isFirefox) {
this.wasLastColorSchemeDark = isDark
}
if (this.user.settings.automation !== 'system') {
return
}
this.callWhenSettingsLoaded(() => {
this.handleAutomationCheck()
})
}
this.handleAutomationCheck = () => {
this.updateAutoState()
const isSwitchedOn = this.isExtensionSwitchedOn()
if (
this.wasEnabledOnLastCheck === null ||
this.wasEnabledOnLastCheck !== isSwitchedOn ||
this.autoState === 'scheme-dark' ||
this.autoState === 'scheme-light'
) {
this.wasEnabledOnLastCheck = isSwitchedOn
this.onAppToggle()
this.tabs.sendMessage()
this.reportChanges()
this.stateManager.saveState()
}
}
this.getTabMessage = (url, frameURL) => {
const settings = this.user.settings
const urlInfo = this.getURLInfo(url)
if (
this.isExtensionSwitchedOn() &&
isURLEnabled(url, settings, urlInfo)
) {
const custom = settings.customThemes.find(({ url: urlList }) =>
isURLInList(url, urlList)
)
const preset = custom
? null
: settings.presets.find(({ urls }) => isURLInList(url, urls))
let theme = custom
? custom.theme
: preset
? preset.theme
: settings.theme
if (
this.autoState === 'scheme-dark' ||
this.autoState === 'scheme-light'
) {
const mode = this.autoState === 'scheme-dark' ? 1 : 0
theme = { ...theme, mode }
}
const isIFrame = frameURL != null
const detectDarkTheme =
!isIFrame &&
settings.detectDarkTheme &&
!isURLInList(url, settings.siteListEnabled) &&
!isPDF(url)
logInfo(`Custom theme ${
custom ? 'was found' : 'was not found'
}, Preset theme ${preset ? 'was found' : 'was not found'}
The theme(${
custom ? 'custom' : preset ? 'preset' : 'global'
} settings) used is: ${JSON.stringify(theme)}`)
switch (theme.engine) {
case ThemeEngines.cssFilter: {
return {
type: MessageType.BG_ADD_CSS_FILTER,
data: {
css: createCSSFilterStyleSheet(
theme,
url,
frameURL,
this.config.INVERSION_FIXES_RAW,
this.config.INVERSION_FIXES_INDEX
),
detectDarkTheme,
},
}
}
case ThemeEngines.svgFilter: {
if (isFirefox) {
return {
type: MessageType.BG_ADD_CSS_FILTER,
data: {
css: createSVGFilterStylesheet(
theme,
url,
frameURL,
this.config.INVERSION_FIXES_RAW,
this.config.INVERSION_FIXES_INDEX
),
detectDarkTheme,
},
}
}
return {
type: MessageType.BG_ADD_SVG_FILTER,
data: {
css: createSVGFilterStylesheet(
theme,
url,
frameURL,
this.config.INVERSION_FIXES_RAW,
this.config.INVERSION_FIXES_INDEX
),
svgMatrix: getSVGFilterMatrixValue(theme),
svgReverseMatrix: getSVGReverseFilterMatrixValue(),
detectDarkTheme,
},
}
}
case ThemeEngines.staticTheme: {
return {
type: MessageType.BG_ADD_STATIC_THEME,
data: {
css:
theme.stylesheet && theme.stylesheet.trim()
? theme.stylesheet
: createStaticStylesheet(
theme,
url,
frameURL,
this.config.STATIC_THEMES_RAW,
this.config.STATIC_THEMES_INDEX
),
detectDarkTheme: settings.detectDarkTheme,
},
}
}
case ThemeEngines.dynamicTheme: {
const fixes = getDynamicThemeFixesFor(
url,
frameURL,
this.config.DYNAMIC_THEME_FIXES_RAW,
this.config.DYNAMIC_THEME_FIXES_INDEX,
this.user.settings.enableForPDF
)
return {
type: MessageType.BG_ADD_DYNAMIC_THEME,
data: {
theme,
fixes,
isIFrame,
detectDarkTheme,
},
}
}
default: {
throw new Error(`Unknown engine ${theme.engine}`)
}
}
}
return {
type: MessageType.BG_CLEAN_UP,
}
}
this.config = new ConfigManager()
this.devtools = new DevTools(this.config, async () =>
this.onSettingsChanged()
)
this.messenger = new Messenger(this.getMessengerAdapter())
this.news = new Newsmaker((news) => this.onNewsUpdate(news))
this.tabs = new TabManager({
getConnectionMessage: ({ url, frameURL }) =>
this.getConnectionMessage(url, frameURL),
getTabMessage: this.getTabMessage,
onColorSchemeChange: this.onColorSchemeChange,
})
this.user = new UserStorage()
this.startBarrier = new PromiseBarrier()
this.stateManager = new StateManager(Extension.LOCAL_STORAGE_KEY, this, {
autoState: '',
wasEnabledOnLastCheck: null,
registeredContextMenus: null,
})
chrome.alarms.onAlarm.addListener(this.alarmListener)
if (chrome.permissions.onRemoved) {
chrome.permissions.onRemoved.addListener((permissions) => {
if (!permissions.permissions.includes('contextMenus')) {
this.registeredContextMenus = false
}
})
}
}
isExtensionSwitchedOn() {
return (
this.autoState === 'turn-on' ||
this.autoState === 'scheme-dark' ||
this.autoState === 'scheme-light' ||
(this.autoState === '' && this.user.settings.enabled)
)
}
updateAutoState() {
const { automation, automationBehaviour: behavior } = this.user.settings
let isAutoDark
let nextCheck
switch (automation) {
case 'time':
const { time } = this.user.settings
isAutoDark = isInTimeIntervalLocal(time.activation, time.deactivation)
nextCheck = nextTimeInterval(time.activation, time.deactivation)
break
case 'system':
if (isMV3) {
isAutoDark = true
break
}
if (isFirefox) {
isAutoDark =
this.wasLastColorSchemeDark == null
? isSystemDarkModeEnabled()
: this.wasLastColorSchemeDark
} else {
isAutoDark = isSystemDarkModeEnabled()
}
break
case 'location': {
const { latitude, longitude } = this.user.settings.location
if (latitude != null && longitude != null) {
isAutoDark = isNightAtLocation(latitude, longitude)
nextCheck = nextTimeChangeAtLocation(latitude, longitude)
}
break
}
}
let state = ''
if (automation) {
if (behavior === 'OnOff') {
state = isAutoDark ? 'turn-on' : 'turn-off'
} else if (behavior === 'Scheme') {
state = isAutoDark ? 'scheme-dark' : 'scheme-light'
}
}
this.autoState = state
if (nextCheck) {
if (nextCheck < Date.now()) {
logWarn(
`Alarm is set in the past: ${nextCheck}. The time is: ${new Date()}. ISO: ${new Date().toISOString()}`
)
} else {
chrome.alarms.create(Extension.ALARM_NAME, { when: nextCheck })
}
}
}
async start() {
await this.config.load({ local: true })
await this.user.loadSettings()
if (
this.user.settings.enableContextMenus &&
!this.registeredContextMenus
) {
chrome.permissions.contains(
{ permissions: ['contextMenus'] },
(permitted) => {
if (permitted) {
this.registerContextMenus()
}
}
)
}
if (this.user.settings.syncSitesFixes) {
await this.config.load({ local: false })
}
this.updateAutoState()
this.onAppToggle()
logInfo('loaded', this.user.settings)
if (isThunderbird) {
this.tabs.registerMailDisplayScript()
} else {
this.tabs.updateContentScript({
runOnProtectedPages: this.user.settings.enableForProtectedPages,
})
}
this.user.settings.fetchNews && this.news.subscribe()
this.startBarrier.resolve()
}
getMessengerAdapter() {
return {
collect: async () => {
return await this.collectData()
},
changeSettings: (settings) => this.changeSettings(settings),
setTheme: (theme) => this.setTheme(theme),
setShortcut: ({ command, shortcut }) =>
this.setShortcut(command, shortcut),
toggleActiveTab: async () => this.toggleActiveTab(),
markNewsAsRead: async (ids) => await this.news.markAsRead(...ids),
markNewsAsDisplayed: async (ids) =>
await this.news.markAsDisplayed(...ids),
onPopupOpen: () =>
this.popupOpeningListener && this.popupOpeningListener(),
loadConfig: async (options) => await this.config.load(options),
applyDevDynamicThemeFixes: (text) =>
this.devtools.applyDynamicThemeFixes(text),
resetDevDynamicThemeFixes: () => this.devtools.resetDynamicThemeFixes(),
applyDevInversionFixes: (text) =>
this.devtools.applyInversionFixes(text),
resetDevInversionFixes: () => this.devtools.resetInversionFixes(),
applyDevStaticThemes: (text) => this.devtools.applyStaticThemes(text),
resetDevStaticThemes: () => this.devtools.resetStaticThemes(),
}
}
registerContextMenus() {
const onCommandToggle = async () => this.onCommand('toggle')
const onCommandAddSite = async (data) =>
this.onCommand('addSite', data.frameUrl)
const onCommandSwitchEngine = async () => this.onCommand('switchEngine')
chrome.contextMenus.removeAll(() => {
this.registeredContextMenus = false
chrome.contextMenus.create(
{
id: 'DarkReader-top',
title: 'Dark Reader',
},
() => {
if (chrome.runtime.lastError) {
return
}
const msgToggle = chrome.i18n.getMessage('toggle_extension')
const msgAddSite = chrome.i18n.getMessage('toggle_current_site')
const msgSwitchEngine = chrome.i18n.getMessage(
'theme_generation_mode'
)
chrome.contextMenus.create({
id: 'DarkReader-toggle',
parentId: 'DarkReader-top',
title: msgToggle || 'Toggle everywhere',
onclick: onCommandToggle,
})
chrome.contextMenus.create({
id: 'DarkReader-addSite',
parentId: 'DarkReader-top',
title: msgAddSite || 'Toggle for current site',
onclick: onCommandAddSite,
})
chrome.contextMenus.create({
id: 'DarkReader-switchEngine',
parentId: 'DarkReader-top',
title: msgSwitchEngine || 'Switch engine',
onclick: onCommandSwitchEngine,
})
this.registeredContextMenus = true
}
)
})
}
async getShortcuts() {
const commands = await getCommands()
return commands.reduce(
(map, cmd) => Object.assign(map, { [cmd.name]: cmd.shortcut }),
{}
)
}
setShortcut(command, shortcut) {
setShortcut(command, shortcut)
}
async collectData() {
if (!this.user.settings) {
await this.user.loadSettings()
}
await this.stateManager.loadState()
return {
isEnabled: this.isExtensionSwitchedOn(),
isReady: true,
settings: this.user.settings,
news: await this.news.getLatest(),
shortcuts: await this.getShortcuts(),
colorScheme: this.config.COLOR_SCHEMES_RAW,
forcedScheme:
this.autoState === 'scheme-dark'
? 'dark'
: this.autoState === 'scheme-light'
? 'light'
: null,
devtools: {
dynamicFixesText: await this.devtools.getDynamicThemeFixesText(),
filterFixesText: await this.devtools.getInversionFixesText(),
staticThemesText: await this.devtools.getStaticThemesText(),
hasCustomDynamicFixes:
await this.devtools.hasCustomDynamicThemeFixes(),
hasCustomFilterFixes: await this.devtools.hasCustomFilterFixes(),
hasCustomStaticFixes: await this.devtools.hasCustomStaticFixes(),
},
activeTab: await this.getActiveTabInfo(),
}
}
async getActiveTabInfo() {
if (!this.user.settings) {
await this.user.loadSettings()
}
await this.stateManager.loadState()
const url = await this.tabs.getActiveTabURL()
const info = this.getURLInfo(url)
info.isInjected = await this.tabs.canAccessActiveTab()
if (this.user.settings.detectDarkTheme) {
info.isDarkThemeDetected =
await this.tabs.isActiveTabDarkThemeDetected()
}
return info
}
onNewsUpdate(news) {
if (!this.icon) {
this.icon = new IconManager()
}
const latestNews = news.length > 0 && news[0]
if (
latestNews &&
latestNews.badge &&
!latestNews.read &&
!latestNews.displayed
) {
this.icon.showBadge(latestNews.badge)
return
}
this.icon.hideBadge()
}
getConnectionMessage(url, frameURL) {
if (this.user.settings) {
return this.getTabMessage(url, frameURL)
}
return new Promise((resolve) => {
this.user
.loadSettings()
.then(() => resolve(this.getTabMessage(url, frameURL)))
})
}
callWhenSettingsLoaded(callback) {
if (this.user.settings) {
callback()
return
}
this.user.loadSettings().then(async () => {
await this.stateManager.loadState()
callback()
})
}
changeSettings($settings) {
const prev = { ...this.user.settings }
this.user.set($settings)
if (
prev.enabled !== this.user.settings.enabled ||
prev.automation !== this.user.settings.automation ||
prev.automationBehaviour !== this.user.settings.automationBehaviour ||
prev.time.activation !== this.user.settings.time.activation ||
prev.time.deactivation !== this.user.settings.time.deactivation ||
prev.location.latitude !== this.user.settings.location.latitude ||
prev.location.longitude !== this.user.settings.location.longitude
) {
this.updateAutoState()
this.onAppToggle()
}
if (prev.syncSettings !== this.user.settings.syncSettings) {
this.user.saveSyncSetting(this.user.settings.syncSettings)
}
if (
this.isExtensionSwitchedOn() &&
$settings.changeBrowserTheme != null &&
prev.changeBrowserTheme !== $settings.changeBrowserTheme
) {
if ($settings.changeBrowserTheme) {
setWindowTheme(this.user.settings.theme)
} else {
resetWindowTheme()
}
}
if (prev.fetchNews !== this.user.settings.fetchNews) {
this.user.settings.fetchNews
? this.news.subscribe()
: this.news.unSubscribe()
}
if (prev.enableContextMenus !== this.user.settings.enableContextMenus) {
if (this.user.settings.enableContextMenus) {
this.registerContextMenus()
} else {
chrome.contextMenus.removeAll()
}
}
this.onSettingsChanged()
}
setTheme($theme) {
this.user.set({ theme: { ...this.user.settings.theme, ...$theme } })
if (
this.isExtensionSwitchedOn() &&
this.user.settings.changeBrowserTheme
) {
setWindowTheme(this.user.settings.theme)
}
this.onSettingsChanged()
}
async reportChanges() {
const info = await this.collectData()
this.messenger.reportChanges(info)
}
async toggleActiveTab() {
const settings = this.user.settings
const tab = await this.getActiveTabInfo()
const { url } = tab
const isInDarkList = isURLInList(url, this.config.DARK_SITES)
const host = getURLHostOrProtocol(url)
function getToggledList(sourceList) {
const list = sourceList.slice()
const index = list.indexOf(host)
if (index < 0) {
list.push(host)
} else {
list.splice(index, 1)
}
return list
}
const darkThemeDetected =
!settings.applyToListedOnly &&
settings.detectDarkTheme &&
tab.isDarkThemeDetected
if (
isInDarkList ||
darkThemeDetected ||
settings.siteListEnabled.includes(host)
) {
const toggledList = getToggledList(settings.siteListEnabled)
this.changeSettings({ siteListEnabled: toggledList })
return
}
const toggledList = getToggledList(settings.siteList)
this.changeSettings({ siteList: toggledList })
}
onAppToggle() {
if (!this.icon) {
this.icon = new IconManager()
}
if (this.isExtensionSwitchedOn()) {
this.icon.setActive()
if (this.user.settings.changeBrowserTheme) {
setWindowTheme(this.user.settings.theme)
}
} else {
this.icon.setInactive()
if (this.user.settings.changeBrowserTheme) {
resetWindowTheme()
}
}
}
async onSettingsChanged() {
if (!this.user.settings) {
await this.user.loadSettings()
}
await this.stateManager.loadState()
this.wasEnabledOnLastCheck = this.isExtensionSwitchedOn()
this.tabs.sendMessage()
this.saveUserSettings()
this.reportChanges()
this.stateManager.saveState()
}
onRemoteSettingsChange() {}
getURLInfo(url) {
const { DARK_SITES } = this.config
const isInDarkList = isURLInList(url, DARK_SITES)
const isProtected = !canInjectScript(url)
return {
url,
isInDarkList,
isProtected,
isInjected: null,
isDarkThemeDetected: null,
}
}
async saveUserSettings() {
await this.user.saveSettings()
logInfo('saved', this.user.settings)
}
}
Extension.ALARM_NAME = 'auto-time-alarm'
Extension.LOCAL_STORAGE_KEY = 'Extension-state'
function makeChromiumHappy() {
chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
if (
![
MessageType.CS_FRAME_CONNECT,
MessageType.UI_GET_DATA,
MessageType.UI_APPLY_DEV_DYNAMIC_THEME_FIXES,
MessageType.UI_APPLY_DEV_INVERSION_FIXES,
MessageType.UI_APPLY_DEV_STATIC_THEMES,
].includes(message.type)
) {
sendResponse({ type: '¯\\_(ツ)_/¯' })
}
})
}
const UNINSTALL_URL =
'https://ext.aitools.band/dark-mode-google-docs/uninstall'
chrome.runtime.setUninstallURL(UNINSTALL_URL)
const extension = new Extension()
extension.start()
if (chrome.commands) {
chrome.commands.onCommand.addListener(async (command) =>
extension.onCommand(command)
)
}
const welcome = `Hi!`
console.log(welcome)
{
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === chrome.runtime.OnInstalledReason.INSTALL) {
// Code to be executed on first install
// eg. open a tab with a url
chrome.storage.sync.set({
welcomePageOpened: true
})
chrome.tabs.create({
url: 'https://ext.aitools.band/dark-mode-google-docs/congrats',
})
chrome.storage.sync.set({
deviceId: crypto.randomUUID()
})
} else if (details.reason === chrome.runtime.OnInstalledReason.UPDATE) {
// When extension is updated
} else if (
details.reason === chrome.runtime.OnInstalledReason.CHROME_UPDATE
) {
// When browser is updated
} else if (
details.reason === chrome.runtime.OnInstalledReason.SHARED_MODULE_UPDATE
) {
// When a shared module is updated
}
})
}
makeChromiumHappy()
chrome.storage.sync.get(['deviceId'], (result) => {
if (!result.deviceId) {
chrome.storage.sync.set({ deviceId: crypto.randomUUID() })
}
})
})()