Run the page script in the MAIN world and communicate with it via BroadcastChannel instead of having it monitor a <script> tag

This commit is contained in:
Jonny Buchanan 2025-06-01 22:37:28 +10:00
parent 15b33a6d55
commit ab85ad9bb5
7 changed files with 197 additions and 176 deletions

View File

@ -1,7 +1,7 @@
//#region Constants
const IS_SAFARI = navigator.userAgent.includes('Safari/') && !/Chrom(e|ium)\//.test(navigator.userAgent)
/**
* Config keys whose changes should be passed to the page script.
* Config keys which should be passed to the page script.
* @type {import("./types").StoredConfigKey[]}
*/
const PAGE_SCRIPT_CONFIG_KEYS = ['debug', 'debugLogTimelineStats', 'enabled', 'settings']
@ -12,11 +12,17 @@ const TWITTER_LOGO_PATH = 'M23.643 4.937c-.835.37-1.732.62-2.675.733.962-.576 1.
const X_LOGO_PATH = 'M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z'
//#endregion
//#region Variables
/** @type {BroadcastChannel} */
let channel
//#endregion
//#region Functions
function error(...messages) {
console.error('[content]', ...messages)
console.error('[content]', ...messages)
}
// Can't import this from storage.js in a content script
function get(keys) {
return new Promise((resolve, reject) => {
chrome.storage.local.get(keys, (result) => {
@ -29,6 +35,46 @@ function get(keys) {
})
}
/**
* @param {chrome.storage.StorageChange} settingsChange
* @param {import("./types").UserSettingsKey} key
*/
function hasSettingChanged(settingsChange, key) {
return (
settingsChange.newValue &&
Object.hasOwn(settingsChange.newValue, key) && (
!settingsChange.oldValue ||
!Object.hasOwn(settingsChange.oldValue, key) ||
settingsChange.oldValue[key] != settingsChange.newValue[key]
))
}
/**
* Pass relevant storage changes to the page script.
* @param {{[key: string]: chrome.storage.StorageChange}} storageChanges
*/
function onStorageChanged(storageChanges) {
if (storageChanges.enabled) {
localStorage.cpftEnabled = storageChanges.enabled.newValue
}
if (storageChanges.settings && hasSettingChanged(storageChanges.settings, 'revertXBranding')) {
localStorage.cpftRevertXBranding = storageChanges.settings.newValue.revertXBranding
}
/** @type {Partial<import("./types").StoredConfig>} */
let config = Object.fromEntries(
Object.entries(storageChanges)
.filter(([key]) => PAGE_SCRIPT_CONFIG_KEYSET.has(key))
.map(([key, {newValue}]) => [key, newValue])
)
// Ignore storage changes which aren't relevant to the page script
if (Object.keys(config).length == 0) return
channel.postMessage({type: 'change', config})
}
// Can't import this from storage.js in a content script
function set(keys) {
return new Promise((resolve, reject) => {
chrome.storage.local.set(keys, () => {
@ -40,8 +86,34 @@ function set(keys) {
})
})
}
/** @param {MessageEvent<Partial<import("./types").StoredConfig>>} message */
async function storeConfigChangesFromPageScript({data: changes}) {
let configToStore = {}
if (changes.version) {
configToStore.version = changes.version
}
try {
if (changes.settings) {
let {settings} = await get({settings: {}})
configToStore.settings = {...settings, ...changes.settings}
}
} catch(e) {
error('error merging settings change from page script', e)
}
chrome.storage.local.onChanged.removeListener(onStorageChanged)
try {
await set(configToStore)
} catch(e) {
error('error storing settings change from page script', e)
} finally {
chrome.storage.local.onChanged.addListener(onStorageChanged)
}
}
//#endregion
//#region Main
// Replace the X logo on initial load before the page script runs
if (localStorage.cpftEnabled != 'false' && localStorage.cpftRevertXBranding != 'false') {
if (!IS_SAFARI) {
@ -71,77 +143,17 @@ if (localStorage.cpftEnabled != 'false' && localStorage.cpftRevertXBranding != '
}
}
// TODO Replace with BroadcastChannel
/** @type {HTMLScriptElement} */
let $settings
// Get initial config and inject it and the page script
get({
debug: false,
debugLogTimelineStats: false,
settings: {},
}).then((storedConfig) => {
$settings = document.createElement('script')
$settings.type = 'text/json'
$settings.id = 'cpftSettings'
document.documentElement.appendChild($settings)
$settings.innerText = JSON.stringify(storedConfig)
let $pageScript = document.createElement('script')
$pageScript.src = chrome.runtime.getURL('script.js')
/** @this {HTMLScriptElement} */
$pageScript.onload = function() {
this.remove()
}
document.documentElement.appendChild($pageScript)
chrome.storage.onChanged.addListener(onStorageChanged)
})
/**
* Pass relevant storage changes to the page script.
* @param {{[key: string]: chrome.storage.StorageChange}} storageChanges
*/
function onStorageChanged(storageChanges) {
if (storageChanges.enabled) localStorage.cpftEnabled = storageChanges.enabled.newValue
if (storageChanges.settings && storageChanges.settings.newValue?.revertXBranding)
localStorage.cpftRevertXBranding = storageChanges.settings.newValue.revertXBranding
let changes = Object.fromEntries(
Object.entries(storageChanges)
.filter(([key]) => PAGE_SCRIPT_CONFIG_KEYSET.has(key))
.map(([key, {newValue}]) => [key, newValue])
)
if (Object.keys(changes).length > 0) {
$settings.innerText = JSON.stringify(changes)
}
}
// Store settings changes from the page script
window.addEventListener('message', async (event) => {
window.addEventListener('message', (event) => {
if (event.source !== window) return
if (event.data.type == 'cpft_config_change' && event.data.changes) {
/** @type {Partial<import("./types").StoredConfig>} */
let changes = event.data.changes
let configToStore = {}
if (changes.version) {
configToStore.version = changes.version
}
try {
if (changes.settings) {
let {settings} = await get({settings: {}})
configToStore.settings = {...settings, ...changes.settings}
}
} catch(e) {
error('error merging settings change from page script', e)
}
chrome.storage.onChanged.removeListener(onStorageChanged)
try {
await set(configToStore)
} catch(e) {
error('error storing settings change from page script', e)
} finally {
chrome.storage.onChanged.addListener(onStorageChanged)
}
}
})
if (event.data.type != 'init' || !event.data.channelName) return
channel = new BroadcastChannel(event.data.channelName)
channel.addEventListener('message', storeConfigChangesFromPageScript)
chrome.storage.local.get((storedConfig) => {
let config = Object.fromEntries(
Object.entries(storedConfig).filter(([key]) => PAGE_SCRIPT_CONFIG_KEYSET.has(key))
)
chrome.storage.local.onChanged.addListener(onStorageChanged)
channel.postMessage({type: 'initial', config})
})
})
//#endregion

View File

@ -32,11 +32,21 @@
"content.js"
],
"run_at": "document_start"
},
{
"world": "MAIN",
"matches": [
"https://twitter.com/*",
"https://mobile.twitter.com/*",
"https://x.com/*",
"https://mobile.x.com/*"
],
"js": [
"script.js"
],
"run_at": "document_start"
}
],
"web_accessible_resources": [
"script.js"
],
"options_ui": {
"browser_style": true,
"page": "options.html"

View File

@ -29,19 +29,19 @@
"content.js"
],
"run_at": "document_start"
}
],
"web_accessible_resources": [
},
{
"world": "MAIN",
"matches": [
"https://twitter.com/*",
"https://mobile.twitter.com/*",
"https://x.com/*",
"https://mobile.x.com/*"
],
"resources": [
"js": [
"script.js"
]
],
"run_at": "document_start"
}
],
"options_ui": {

View File

@ -360,14 +360,12 @@ function shouldDisplayMutedQuotes() {
* @param {Partial<import("./types").StoredConfig>} changes
*/
async function storeConfigChanges(changes) {
chrome.runtime.sendMessage({type: 'settings_change', changes})
/** @type {Partial<import("./types").StoredConfig>} */
let changesToStore = {}
if (Object.hasOwn(changes, 'debug')) {
changesToStore.debug = changes.debug
}
if (Object.hasOwn(changes, 'debugLogTimelineStats')) {
changesToStore.debugLogTimelineStats = changes.debugLogTimelineStats
for (let key of INTERNAL_CONFIG_OPTIONS) {
if (Object.hasOwn(changes, key)) {
changesToStore[key] = changes[key]
}
}
try {
if (changes.settings) {

View File

@ -28,19 +28,19 @@
"content.js"
],
"run_at": "document_start"
}
],
"web_accessible_resources": [
},
{
"world": "MAIN",
"matches": [
"https://twitter.com/*",
"https://mobile.twitter.com/*",
"https://x.com/*",
"https://mobile.x.com/*"
],
"resources": [
"js": [
"script.js"
]
],
"run_at": "document_start"
}
],
"options_ui": {

160
script.js
View File

@ -7049,6 +7049,9 @@ function tweakTweetEngagementPage() {
//#endregion
//#region Main
let channelName = crypto.randomUUID()
let channel = new BroadcastChannel(channelName)
async function main() {
// Don't run on URLs used for OAuth
if (location.pathname.startsWith('/i/oauth2/authorize') ||
@ -7096,7 +7099,7 @@ async function main() {
storeConfigChanges({version})
if (lastFlexDirection == null) {
log('initial config', {config: settings, lang, version})
log('initial config', {enabled, config: settings, lang, version})
// One-time setup
checkReactNativeStylesheet()
@ -7202,97 +7205,90 @@ function onSettingsChanged(changedSettings = new Set()) {
}
}
// Initial config and config changes are injected into a <script> element
let $settings = /** @type {HTMLScriptElement} */ (document.querySelector('script#cpftSettings'))
/** @type {import("./types").StoredConfig} */
let storedConfig = {}
if ($settings) {
$settings.removeAttribute('id')
try {
storedConfig = JSON.parse($settings.innerText)
} catch(e) {
error('error parsing initial settings', e)
}
if (Object.hasOwn(storedConfig, 'enabled')) {
enabled = storedConfig.enabled
}
if (Object.hasOwn(storedConfig, 'debug')) {
debug = storedConfig.debug
}
if (Object.hasOwn(storedConfig, 'debugLogTimelineStats')) {
debugLogTimelineStats = storedConfig.debugLogTimelineStats
}
settings = {...defaultSettings, ...storedConfig.settings}
let settingsChangeObserver = new MutationObserver(() => {
/** @type {Partial<import("./types").StoredConfig>} */
let configChanges
try {
configChanges = JSON.parse($settings.innerText)
} catch(e) {
error('error parsing incoming settings change', e)
return
/**
* @param {MessageEvent<import("./types").StoredConfigMessage>} message
*/
function receiveConfigFromContentScript({data: {type, config}}) {
if (type == 'initial') {
if (Object.hasOwn(config, 'enabled')) {
enabled = config.enabled
}
if (Object.hasOwn(configChanges, 'enabled')) {
enabled = configChanges.enabled
log(`${enabled ? 'en' : 'dis'}abling extension functionality`)
if (enabled) {
// Process the current page if we've just been enabled on it
observingPageChanges = true
main()
} else {
// These functions have teardowns when disabled
configureCss()
configureFont()
configureDynamicCss()
configureThemeCss()
// Manually remove custom UI elements which clone existing elements, as
// adding a hidden attribute won't hide them by default.
document.querySelector('#cpftSeparatedTweetsTab')?.remove()
document.querySelectorAll('.cpft_menu_item').forEach(el => el.remove())
disconnectObservers(modalObservers, 'modal')
disconnectObservers(pageObservers, 'page')
disconnectObservers(globalObservers, 'global')
}
return
if (Object.hasOwn(config, 'debug')) {
debug = config.debug
}
if (Object.hasOwn(config, 'debugLogTimelineStats')) {
debugLogTimelineStats = config.debugLogTimelineStats
}
settings = {...defaultSettings, ...config.settings}
if (Object.hasOwn(configChanges, 'debug')) {
log('disabling debug mode')
debug = configChanges.debug
log('enabled debug mode')
main()
return
}
if (Object.hasOwn(config, 'enabled')) {
enabled = config.enabled
log(`${enabled ? 'en' : 'dis'}abling extension functionality`)
if (enabled) {
// Process the current page if we've just been enabled on it
observingPageChanges = true
main()
} else {
// These functions have teardowns when disabled
configureCss()
configureFont()
configureDynamicCss()
configureThemeCss()
return
configureCustomCss()
// Manually remove custom UI elements which clone existing elements, as
// adding a hidden attribute won't hide them by default.
document.querySelector('#cpftSeparatedTweetsTab')?.remove()
document.querySelectorAll('.cpft_menu_item').forEach(el => el.remove())
disconnectObservers(modalObservers, 'modal')
disconnectObservers(pageObservers, 'page')
disconnectObservers(globalObservers, 'global')
}
return
}
if (Object.hasOwn(configChanges, 'debugLogTimelineStats')) {
debugLogTimelineStats = configChanges.debugLogTimelineStats
return
}
if (Object.hasOwn(config, 'debug')) {
log('disabling debug mode')
debug = config.debug
log('enabled debug mode')
// Reconfigure CSS to display debug annotations
configureThemeCss()
return
}
/** @type {Set<import("./types").UserSettingsKey>} */
let changedSettings
if (Object.hasOwn(configChanges, 'settings')) {
/** @type {import("./types").UserSettingsKey[]} */
let settingsWithSpecialHandling = [
'hideNotifications',
'redirectToTwitter',
'revertXBranding',
]
changedSettings = new Set(settingsWithSpecialHandling.filter(
(key) => Object.hasOwn(configChanges.settings, key) && configChanges.settings[key] != settings[key]
))
Object.assign(settings, configChanges.settings)
}
if (Object.hasOwn(config, 'debugLogTimelineStats')) {
debugLogTimelineStats = config.debugLogTimelineStats
log(`${debugLogTimelineStats ? 'en' : 'dis'}abled logging of timeline stats`)
return
}
onSettingsChanged(changedSettings)
})
settingsChangeObserver.observe($settings, {childList: true})
/** @type {Set<import("./types").UserSettingsKey>} */
let changedSettings
if (Object.hasOwn(config, 'settings')) {
/** @type {import("./types").UserSettingsKey[]} */
let settingsWithSpecialHandling = [
'hideNotifications',
'redirectToTwitter',
'revertXBranding',
]
changedSettings = new Set(settingsWithSpecialHandling.filter(
(key) => Object.hasOwn(config.settings, key) && config.settings[key] != settings[key]
))
Object.assign(settings, config.settings)
}
onSettingsChanged(changedSettings)
}
main()
/** @param {Partial<import("./types").StoredConfig>} changes */
function storeConfigChanges(changes) {
channel.postMessage(changes)
}
channel.addEventListener('message', receiveConfigFromContentScript)
window.postMessage({type: 'init', channelName}, location.origin)
//#endregion
}()

5
types.d.ts vendored
View File

@ -16,6 +16,11 @@ export type StoredConfig = {
export type StoredConfigKey = keyof StoredConfig
export type StoredConfigMessage = {
type: 'initial' | 'change'
config: Partial<StoredConfig>
}
export type UserSettings = {
// Shared
addAddMutedWordMenuItem: boolean