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 //#region Constants
const IS_SAFARI = navigator.userAgent.includes('Safari/') && !/Chrom(e|ium)\//.test(navigator.userAgent) 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[]} * @type {import("./types").StoredConfigKey[]}
*/ */
const PAGE_SCRIPT_CONFIG_KEYS = ['debug', 'debugLogTimelineStats', 'enabled', 'settings'] 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' 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 //#endregion
//#region Variables
/** @type {BroadcastChannel} */
let channel
//#endregion
//#region Functions //#region Functions
function error(...messages) { 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) { function get(keys) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
chrome.storage.local.get(keys, (result) => { 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) { function set(keys) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
chrome.storage.local.set(keys, () => { 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 //#endregion
//#region Main
// Replace the X logo on initial load before the page script runs // Replace the X logo on initial load before the page script runs
if (localStorage.cpftEnabled != 'false' && localStorage.cpftRevertXBranding != 'false') { if (localStorage.cpftEnabled != 'false' && localStorage.cpftRevertXBranding != 'false') {
if (!IS_SAFARI) { if (!IS_SAFARI) {
@ -71,77 +143,17 @@ if (localStorage.cpftEnabled != 'false' && localStorage.cpftRevertXBranding != '
} }
} }
// TODO Replace with BroadcastChannel window.addEventListener('message', (event) => {
/** @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) => {
if (event.source !== window) return if (event.source !== window) return
if (event.data.type == 'cpft_config_change' && event.data.changes) { if (event.data.type != 'init' || !event.data.channelName) return
/** @type {Partial<import("./types").StoredConfig>} */ channel = new BroadcastChannel(event.data.channelName)
let changes = event.data.changes channel.addEventListener('message', storeConfigChangesFromPageScript)
let configToStore = {} chrome.storage.local.get((storedConfig) => {
if (changes.version) { let config = Object.fromEntries(
configToStore.version = changes.version Object.entries(storedConfig).filter(([key]) => PAGE_SCRIPT_CONFIG_KEYSET.has(key))
} )
try { chrome.storage.local.onChanged.addListener(onStorageChanged)
if (changes.settings) { channel.postMessage({type: 'initial', config})
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)
}
}
}) })
})
//#endregion

View File

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

View File

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

View File

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

View File

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

View File

@ -7049,6 +7049,9 @@ function tweakTweetEngagementPage() {
//#endregion //#endregion
//#region Main //#region Main
let channelName = crypto.randomUUID()
let channel = new BroadcastChannel(channelName)
async function main() { async function main() {
// Don't run on URLs used for OAuth // Don't run on URLs used for OAuth
if (location.pathname.startsWith('/i/oauth2/authorize') || if (location.pathname.startsWith('/i/oauth2/authorize') ||
@ -7096,7 +7099,7 @@ async function main() {
storeConfigChanges({version}) storeConfigChanges({version})
if (lastFlexDirection == null) { if (lastFlexDirection == null) {
log('initial config', {config: settings, lang, version}) log('initial config', {enabled, config: settings, lang, version})
// One-time setup // One-time setup
checkReactNativeStylesheet() checkReactNativeStylesheet()
@ -7202,41 +7205,28 @@ function onSettingsChanged(changedSettings = new Set()) {
} }
} }
// Initial config and config changes are injected into a <script> element /**
let $settings = /** @type {HTMLScriptElement} */ (document.querySelector('script#cpftSettings')) * @param {MessageEvent<import("./types").StoredConfigMessage>} message
/** @type {import("./types").StoredConfig} */ */
let storedConfig = {} function receiveConfigFromContentScript({data: {type, config}}) {
if ($settings) { if (type == 'initial') {
$settings.removeAttribute('id') if (Object.hasOwn(config, 'enabled')) {
try { enabled = config.enabled
storedConfig = JSON.parse($settings.innerText)
} catch(e) {
error('error parsing initial settings', e)
} }
if (Object.hasOwn(config, 'debug')) {
debug = config.debug
}
if (Object.hasOwn(config, 'debugLogTimelineStats')) {
debugLogTimelineStats = config.debugLogTimelineStats
}
settings = {...defaultSettings, ...config.settings}
if (Object.hasOwn(storedConfig, 'enabled')) { main()
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 return
} }
if (Object.hasOwn(configChanges, 'enabled')) { if (Object.hasOwn(config, 'enabled')) {
enabled = configChanges.enabled enabled = config.enabled
log(`${enabled ? 'en' : 'dis'}abling extension functionality`) log(`${enabled ? 'en' : 'dis'}abling extension functionality`)
if (enabled) { if (enabled) {
// Process the current page if we've just been enabled on it // Process the current page if we've just been enabled on it
@ -7248,6 +7238,7 @@ if ($settings) {
configureFont() configureFont()
configureDynamicCss() configureDynamicCss()
configureThemeCss() configureThemeCss()
configureCustomCss()
// Manually remove custom UI elements which clone existing elements, as // Manually remove custom UI elements which clone existing elements, as
// adding a hidden attribute won't hide them by default. // adding a hidden attribute won't hide them by default.
document.querySelector('#cpftSeparatedTweetsTab')?.remove() document.querySelector('#cpftSeparatedTweetsTab')?.remove()
@ -7259,22 +7250,24 @@ if ($settings) {
return return
} }
if (Object.hasOwn(configChanges, 'debug')) { if (Object.hasOwn(config, 'debug')) {
log('disabling debug mode') log('disabling debug mode')
debug = configChanges.debug debug = config.debug
log('enabled debug mode') log('enabled debug mode')
// Reconfigure CSS to display debug annotations
configureThemeCss() configureThemeCss()
return return
} }
if (Object.hasOwn(configChanges, 'debugLogTimelineStats')) { if (Object.hasOwn(config, 'debugLogTimelineStats')) {
debugLogTimelineStats = configChanges.debugLogTimelineStats debugLogTimelineStats = config.debugLogTimelineStats
log(`${debugLogTimelineStats ? 'en' : 'dis'}abled logging of timeline stats`)
return return
} }
/** @type {Set<import("./types").UserSettingsKey>} */ /** @type {Set<import("./types").UserSettingsKey>} */
let changedSettings let changedSettings
if (Object.hasOwn(configChanges, 'settings')) { if (Object.hasOwn(config, 'settings')) {
/** @type {import("./types").UserSettingsKey[]} */ /** @type {import("./types").UserSettingsKey[]} */
let settingsWithSpecialHandling = [ let settingsWithSpecialHandling = [
'hideNotifications', 'hideNotifications',
@ -7282,17 +7275,20 @@ if ($settings) {
'revertXBranding', 'revertXBranding',
] ]
changedSettings = new Set(settingsWithSpecialHandling.filter( changedSettings = new Set(settingsWithSpecialHandling.filter(
(key) => Object.hasOwn(configChanges.settings, key) && configChanges.settings[key] != settings[key] (key) => Object.hasOwn(config.settings, key) && config.settings[key] != settings[key]
)) ))
Object.assign(settings, configChanges.settings) Object.assign(settings, config.settings)
} }
onSettingsChanged(changedSettings) onSettingsChanged(changedSettings)
})
settingsChangeObserver.observe($settings, {childList: true})
} }
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 //#endregion
}() }()

5
types.d.ts vendored
View File

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