-
Notifications
You must be signed in to change notification settings - Fork 124
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
MV3: Move translation from sandboxed content iframe to offscreen
Offscreen page doesn't support eval, so we are actually embedding a sandbox iframe there which then supports eval so we can run the translate architecture there. Message passing is technically complicated: Content Scripts <-chrome.runtime.sendMessage-> Background Service Worker (background-worker.js) <-MessagePort-> Offscreen Sandbox Iframe (offscreenSandbox.html) The MessagePort is created by Offscreen Page (offscreen.html) and the two ports are passed respectively to to Offscreen Sandbox Iframe and Background page after which offscreen.html only performs the function of embedding the Offscreen Sandbox and reinitializing the MessagePort in case the Background Service Worker gets restarted by the browser. We are moving translate to offscreen sandbox because the sandbox iframe on content pages are causing issues on some websites where their pages refuse to load due to loading code finding unexpected elements. Also: Added a wrapper to Zotero.Translate.Web which makes translate only be concerned with scrapping the webpage (i.e. the stuff we need to be able to run eval for). Scrapped item saving to Zotero and UI notifications are now handled independently. This was done because 1. The Zotero Translate architecture has no business in doing that stuff anyway 2. If ItemSaver runs in the Offscreen Translate page it then needs to be able to independently send progress callback messages to the tab where translation occurs, but we have no way to pass the tab information with which to communicate to ItemSaver if it is managed by Zotero.Translate, without changing the translate repo. Passing around that information via Zotero.Translate is dumb and creates even more insane and difficult to track interdependencies in translate architecture. As part of this some refactoring to Zotero.Inject also occurred as that namespace has troubled me for a while as more and more random unrelated hard-to-place functions got crammed there. All page saving related functionality has been moved to pageSaving.js
- Loading branch information
Showing
25 changed files
with
1,605 additions
and
1,161 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,139 @@ | ||
/* | ||
***** BEGIN LICENSE BLOCK ***** | ||
Copyright © 2024 Corporation for Digital Scholarship | ||
Vienna, Virginia, USA | ||
http://zotero.org | ||
This file is part of Zotero. | ||
Zotero is free software: you can redistribute it and/or modify | ||
it under the terms of the GNU Affero General Public License as published by | ||
the Free Software Foundation, either version 3 of the License, or | ||
(at your option) any later version. | ||
Zotero is distributed in the hope that it will be useful, | ||
but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
GNU Affero General Public License for more details. | ||
You should have received a copy of the GNU Affero General Public License | ||
along with Zotero. If not, see <http://www.gnu.org/licenses/>. | ||
***** END LICENSE BLOCK ***** | ||
*/ | ||
|
||
/** | ||
* Part of background page. Manages the offscreen page | ||
*/ | ||
Zotero.OffscreenManager = { | ||
initPromise: null, | ||
offscreenPageInitialized: false, | ||
messagingDeferred: Zotero.Promise.defer(), | ||
offscreenUrl: 'offscreen/offscreen.html', | ||
|
||
async init() { | ||
const offscreenPage = await this.getOffscreenPage(); | ||
if (!offscreenPage) { | ||
// Make sure we're waiting for a new deferred | ||
this.messagingDeferred = Zotero.Promise.defer(); | ||
// Create offscreen document | ||
await browser.offscreen.createDocument({ | ||
url: this.offscreenUrl, | ||
reasons: ['DOM_SCRAPING', 'DOM_PARSER'], | ||
justification: 'Scraping the document with Zotero Translators', | ||
}); | ||
} | ||
else { | ||
// Technically the service worker can restart without the offscreen | ||
// page being unloaded per Chrome docs, although not clear whether this would actually happen in practice. | ||
offscreenPage.postMessage('service-worker-restarted'); | ||
} | ||
await this.messagingDeferred.promise; | ||
|
||
// Only need to set the below up once | ||
if (this.offscreenPageInitialized) return; | ||
this.offscreenPageInitialized = true; | ||
|
||
// Watch for browserext event of tab close and inform the offscreen page translate | ||
browser.tabs.onRemoved.addListener((tabId, removeInfo) => { | ||
this.sendMessage('tabClosed', tabId); | ||
}); | ||
|
||
// Run cleanup every 15min | ||
setInterval(() => this.cleanup(), 15*60e3); | ||
Zotero.debug('OffscreenManager: offscreen page initialized'); | ||
}, | ||
|
||
async sendMessage(message, payload, tab, frameId) { | ||
const offscreenPage = await this.getOffscreenPage(); | ||
if (!offscreenPage) { | ||
await this.init(); | ||
} | ||
if (tab) { | ||
payload.push(tab.id, frameId); | ||
} | ||
return await this._messaging.sendMessage(message, payload); | ||
}, | ||
|
||
async addMessageListener(...args) { | ||
const offscreenPage = await this.getOffscreenPage(); | ||
if (!offscreenPage) { | ||
await this.init(); | ||
} | ||
return this._messaging.addMessageListener(...args); | ||
}, | ||
|
||
/** | ||
* onTabRemoved handler should make sure offscreen doesn't hold translate instances | ||
* that are dead and moreover the offscreen page should get killed every now and then by the browser, | ||
* but we want to be extra sure we're not leaking memory | ||
*/ | ||
async cleanup() { | ||
const offscreenPage = await this.getOffscreenPage(); | ||
if (!offscreenPage) return false; | ||
let tabs = await browser.tabs.query({status: "complete", windowType: "normal"}); | ||
let cleanedUpTabIds = await this.sendMessage('translateCleanup', tabs.map(tab => tab.id)); | ||
if (cleanedUpTabIds.length > 0) { | ||
Zotero.logError(new Error(`OffscreenManager: manually cleaned up translates that were kept ` | ||
+ `alive after onTabRemoved ${JSON.stringif(cleanedUpTabIds)}`)); | ||
} | ||
}, | ||
|
||
async getOffscreenPage() { | ||
const matchedClients = await self.clients.matchAll(); | ||
return matchedClients.find(client => client.url.includes(this.offscreenUrl)); | ||
|
||
} | ||
} | ||
|
||
// Listener needs to be added at worker script initialization | ||
self.onmessage = async (e) => { | ||
if (e.data === 'offscreen-port') { | ||
Zotero.debug('OffscreenManager: received the offscreen page port') | ||
// Resolve _initMessaging() in offscreenSandbox.js | ||
let messagingOptions = { | ||
handlerFunctionOverrides: OFFSCREEN_BACKGROUND_OVERRIDES, | ||
} | ||
messagingOptions.sendMessage = (...args) => { | ||
e.ports[0].postMessage(args) | ||
}; | ||
messagingOptions.addMessageListener = (fn) => { | ||
e.ports[0].onmessage = (e) => fn(e.data); | ||
}; | ||
// If the offscreen document got killed by the browser and we restarted it | ||
// we only need to set sendMessage, otherwise previously added message listeners | ||
// will get discarded | ||
if (Zotero.OffscreenManager._messaging) { | ||
Zotero.OffscreenManager._messaging.reinit(messagingOptions); | ||
} | ||
else { | ||
Zotero.OffscreenManager._messaging = new Zotero.MessagingGeneric(messagingOptions); | ||
} | ||
Zotero.debug('OffscreenManager: messaging initialized') | ||
e.ports[0].postMessage(null); | ||
await new Promise(resolve => Zotero.OffscreenManager._messaging.addMessageListener('offscreen-sandbox-initialized', resolve)); | ||
Zotero.debug('OffscreenManager: offscreen sandbox initialized message received') | ||
Zotero.OffscreenManager.messagingDeferred.resolve(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,123 @@ | ||
/* | ||
***** BEGIN LICENSE BLOCK ***** | ||
Copyright © 2024 Corporation for Digital Scholarship | ||
Vienna, Virginia, USA | ||
http://zotero.org | ||
This file is part of Zotero. | ||
Zotero is free software: you can redistribute it and/or modify | ||
it under the terms of the GNU Affero General Public License as published by | ||
the Free Software Foundation, either version 3 of the License, or | ||
(at your option) any later version. | ||
Zotero is distributed in the hope that it will be useful, | ||
but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
GNU Affero General Public License for more details. | ||
You should have received a copy of the GNU Affero General Public License | ||
along with Zotero. If not, see <http://www.gnu.org/licenses/>. | ||
***** END LICENSE BLOCK ***** | ||
*/ | ||
|
||
// A virtual translate that offloads translating to the offscreen page | ||
Zotero.VirtualOffscreenTranslate = class { | ||
translateDoc = null; | ||
|
||
/** | ||
* @returns {Promise<Zotero.VirtualOffscreenTranslate>} | ||
*/ | ||
static async create() { | ||
let translate = new Zotero.VirtualOffscreenTranslate(); | ||
await translate.sendMessage('Translate.new'); | ||
return new Proxy(translate, { | ||
get: (target, property, ...args) => { | ||
if (!target[property] && (property in Zotero.Translate.Web.prototype)) { | ||
return (...args) => { | ||
return target.sendMessage(`Translate.${property}`, args); | ||
} | ||
} | ||
return Reflect.get(target, property, ...args); | ||
} | ||
}); | ||
} | ||
|
||
constructor() { | ||
// Handling for translate.monitorDOMChanges | ||
let mutationObserver; | ||
this.addMessageListener('MutationObserver.observe', ([selector, config]) => { | ||
// We allow at most one observer, or we'll have to keep track of them. Websites | ||
// that need this will only have one translator applying an observer anyway. | ||
if (mutationObserver) mutationObserver.disconnect(); | ||
mutationObserver = new MutationObserver(() => { | ||
// We disconnect immediately because that's what monitorDOMChanges does, and if we don't | ||
// there's an async messaging timeblock where more mutations may occur and result in | ||
// pageModified being called multiple times. | ||
mutationObserver.disconnect(); | ||
return this.sendMessage('MutationObserver.trigger'); | ||
}); | ||
const node = this.translateDoc.querySelector(selector); | ||
mutationObserver.observe(node, config); | ||
}); | ||
} | ||
|
||
getProxy() { | ||
return this.sendMessage('Translate.getProxy'); | ||
} | ||
|
||
async setHandler(name, callback) { | ||
let id = Zotero.Utilities.randomString(10); | ||
await this.sendMessage('Translate.setHandler', [name, id]); | ||
this.addMessageListener(`Translate.onHandler.${name}`, ([remoteId, args]) => { | ||
if (name == 'select') { | ||
args[2] = (...args) => { | ||
this.sendMessage('Translate.selectCallback', [id, args]); | ||
} | ||
} | ||
if (remoteId == id) { | ||
callback(...args); | ||
} | ||
}); | ||
} | ||
|
||
setDocument(doc, updateLiveElements=false) { | ||
this.translateDoc = doc; | ||
if (updateLiveElements) { | ||
for (const checkbox of doc.querySelectorAll('input[type=checkbox]')) { | ||
if (checkbox.checked) { | ||
checkbox.setAttribute('checked', ''); | ||
} | ||
else { | ||
checkbox.removeAttribute('checked'); | ||
} | ||
} | ||
} | ||
return this.sendMessage('Translate.setDocument', [doc.documentElement.outerHTML, doc.location.href, doc.cookie]); | ||
} | ||
|
||
async setTranslator(translators) { | ||
if (!Array.isArray(translators)) { | ||
translators = [translators]; | ||
} | ||
translators = translators.map(t => t.serialize(Zotero.Translator.TRANSLATOR_PASSING_PROPERTIES)); | ||
return this.sendMessage('Translate.setTranslator', [translators]) | ||
} | ||
|
||
async getTranslators(...args) { | ||
let translators = await this.sendMessage('Translate.getTranslators', args); | ||
return translators.map(translator => new Zotero.Translator(translator)); | ||
} | ||
|
||
sendMessage(message, payload=[]) { | ||
return Zotero.OffscreenManager.sendMessage(message, payload) | ||
} | ||
|
||
addMessageListener(...args) { | ||
// Listening for messages from bg page messaging via which OffscreenManager will send messages | ||
// since it doesn't have the ability to send messages directly to tabs itself | ||
return Zotero.Messaging.addMessageListener(...args) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.