chrome-extension.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. 'use strict'
  2. const { app, webContents, BrowserWindow } = require('electron')
  3. const { getAllWebContents } = process.electronBinding('web_contents')
  4. const renderProcessPreferences = process.electronBinding('render_process_preferences').forAllWebContents()
  5. const ipcMainUtils = require('@electron/internal/browser/ipc-main-internal-utils')
  6. const { Buffer } = require('buffer')
  7. const fs = require('fs')
  8. const path = require('path')
  9. const url = require('url')
  10. const util = require('util')
  11. const readFile = util.promisify(fs.readFile)
  12. const writeFile = util.promisify(fs.writeFile)
  13. // Mapping between extensionId(hostname) and manifest.
  14. const manifestMap = {} // extensionId => manifest
  15. const manifestNameMap = {} // name => manifest
  16. const devToolsExtensionNames = new Set()
  17. const generateExtensionIdFromName = function (name) {
  18. return name.replace(/[\W_]+/g, '-').toLowerCase()
  19. }
  20. const isWindowOrWebView = function (webContents) {
  21. const type = webContents.getType()
  22. return type === 'window' || type === 'webview'
  23. }
  24. const isBackgroundPage = function (webContents) {
  25. return webContents.getType() === 'backgroundPage'
  26. }
  27. // Create or get manifest object from |srcDirectory|.
  28. const getManifestFromPath = function (srcDirectory) {
  29. let manifest
  30. let manifestContent
  31. try {
  32. manifestContent = fs.readFileSync(path.join(srcDirectory, 'manifest.json'))
  33. } catch (readError) {
  34. console.warn(`Reading ${path.join(srcDirectory, 'manifest.json')} failed.`)
  35. console.warn(readError.stack || readError)
  36. throw readError
  37. }
  38. try {
  39. manifest = JSON.parse(manifestContent)
  40. } catch (parseError) {
  41. console.warn(`Parsing ${path.join(srcDirectory, 'manifest.json')} failed.`)
  42. console.warn(parseError.stack || parseError)
  43. throw parseError
  44. }
  45. if (!manifestNameMap[manifest.name]) {
  46. const extensionId = generateExtensionIdFromName(manifest.name)
  47. manifestMap[extensionId] = manifestNameMap[manifest.name] = manifest
  48. Object.assign(manifest, {
  49. srcDirectory: srcDirectory,
  50. extensionId: extensionId,
  51. // We can not use 'file://' directly because all resources in the extension
  52. // will be treated as relative to the root in Chrome.
  53. startPage: url.format({
  54. protocol: 'chrome-extension',
  55. slashes: true,
  56. hostname: extensionId,
  57. pathname: manifest.devtools_page
  58. })
  59. })
  60. return manifest
  61. } else if (manifest && manifest.name) {
  62. console.warn(`Attempted to load extension "${manifest.name}" that has already been loaded.`)
  63. return manifest
  64. }
  65. }
  66. // Manage the background pages.
  67. const backgroundPages = {}
  68. const startBackgroundPages = function (manifest) {
  69. if (backgroundPages[manifest.extensionId] || !manifest.background) return
  70. let html
  71. let name
  72. if (manifest.background.page) {
  73. name = manifest.background.page
  74. html = fs.readFileSync(path.join(manifest.srcDirectory, manifest.background.page))
  75. } else {
  76. name = '_generated_background_page.html'
  77. const scripts = manifest.background.scripts.map((name) => {
  78. return `<script src="${name}"></script>`
  79. }).join('')
  80. html = Buffer.from(`<html><body>${scripts}</body></html>`)
  81. }
  82. const contents = webContents.create({
  83. partition: 'persist:__chrome_extension',
  84. isBackgroundPage: true,
  85. sandbox: true,
  86. enableRemoteModule: false
  87. })
  88. backgroundPages[manifest.extensionId] = { html: html, webContents: contents, name: name }
  89. contents.loadURL(url.format({
  90. protocol: 'chrome-extension',
  91. slashes: true,
  92. hostname: manifest.extensionId,
  93. pathname: name
  94. }))
  95. }
  96. const removeBackgroundPages = function (manifest) {
  97. if (!backgroundPages[manifest.extensionId]) return
  98. backgroundPages[manifest.extensionId].webContents.destroy()
  99. delete backgroundPages[manifest.extensionId]
  100. }
  101. const sendToBackgroundPages = function (...args) {
  102. for (const page of Object.values(backgroundPages)) {
  103. if (!page.webContents.isDestroyed()) {
  104. page.webContents._sendInternalToAll(...args)
  105. }
  106. }
  107. }
  108. // Dispatch web contents events to Chrome APIs
  109. const hookWebContentsEvents = function (webContents) {
  110. const tabId = webContents.id
  111. sendToBackgroundPages('CHROME_TABS_ONCREATED')
  112. webContents.on('will-navigate', (event, url) => {
  113. sendToBackgroundPages('CHROME_WEBNAVIGATION_ONBEFORENAVIGATE', {
  114. frameId: 0,
  115. parentFrameId: -1,
  116. processId: webContents.getProcessId(),
  117. tabId: tabId,
  118. timeStamp: Date.now(),
  119. url: url
  120. })
  121. })
  122. webContents.on('did-navigate', (event, url) => {
  123. sendToBackgroundPages('CHROME_WEBNAVIGATION_ONCOMPLETED', {
  124. frameId: 0,
  125. parentFrameId: -1,
  126. processId: webContents.getProcessId(),
  127. tabId: tabId,
  128. timeStamp: Date.now(),
  129. url: url
  130. })
  131. })
  132. webContents.once('destroyed', () => {
  133. sendToBackgroundPages('CHROME_TABS_ONREMOVED', tabId)
  134. })
  135. }
  136. // Handle the chrome.* API messages.
  137. let nextId = 0
  138. ipcMainUtils.handle('CHROME_RUNTIME_CONNECT', function (event, extensionId, connectInfo) {
  139. if (isBackgroundPage(event.sender)) {
  140. throw new Error('chrome.runtime.connect is not supported in background page')
  141. }
  142. const page = backgroundPages[extensionId]
  143. if (!page || page.webContents.isDestroyed()) {
  144. throw new Error(`Connect to unknown extension ${extensionId}`)
  145. }
  146. const tabId = page.webContents.id
  147. const portId = ++nextId
  148. event.sender.once('render-view-deleted', () => {
  149. if (page.webContents.isDestroyed()) return
  150. page.webContents._sendInternalToAll(`CHROME_PORT_DISCONNECT_${portId}`)
  151. })
  152. page.webContents._sendInternalToAll(`CHROME_RUNTIME_ONCONNECT_${extensionId}`, event.sender.id, portId, connectInfo)
  153. return { tabId, portId }
  154. })
  155. ipcMainUtils.handle('CHROME_EXTENSION_MANIFEST', function (event, extensionId) {
  156. const manifest = manifestMap[extensionId]
  157. if (!manifest) {
  158. throw new Error(`Invalid extensionId: ${extensionId}`)
  159. }
  160. return manifest
  161. })
  162. ipcMainUtils.handle('CHROME_RUNTIME_SEND_MESSAGE', async function (event, extensionId, message) {
  163. if (isBackgroundPage(event.sender)) {
  164. throw new Error('chrome.runtime.sendMessage is not supported in background page')
  165. }
  166. const page = backgroundPages[extensionId]
  167. if (!page || page.webContents.isDestroyed()) {
  168. throw new Error(`Connect to unknown extension ${extensionId}`)
  169. }
  170. return ipcMainUtils.invokeInWebContents(page.webContents, true, `CHROME_RUNTIME_ONMESSAGE_${extensionId}`, event.sender.id, message)
  171. })
  172. ipcMainUtils.handle('CHROME_TABS_SEND_MESSAGE', async function (event, tabId, extensionId, message) {
  173. const contents = webContents.fromId(tabId)
  174. if (!contents) {
  175. throw new Error(`Sending message to unknown tab ${tabId}`)
  176. }
  177. const senderTabId = isBackgroundPage(event.sender) ? null : event.sender.id
  178. return ipcMainUtils.invokeInWebContents(contents, true, `CHROME_RUNTIME_ONMESSAGE_${extensionId}`, senderTabId, message)
  179. })
  180. const getLanguage = () => {
  181. return app.getLocale().replace(/-.*$/, '').toLowerCase()
  182. }
  183. const getMessagesPath = (extensionId) => {
  184. const metadata = manifestMap[extensionId]
  185. if (!metadata) {
  186. throw new Error(`Invalid extensionId: ${extensionId}`)
  187. }
  188. const localesDirectory = path.join(metadata.srcDirectory, '_locales')
  189. const language = getLanguage()
  190. try {
  191. const filename = path.join(localesDirectory, language, 'messages.json')
  192. fs.accessSync(filename, fs.constants.R_OK)
  193. return filename
  194. } catch {
  195. const defaultLocale = metadata.default_locale || 'en'
  196. return path.join(localesDirectory, defaultLocale, 'messages.json')
  197. }
  198. }
  199. ipcMainUtils.handle('CHROME_GET_MESSAGES', async function (event, extensionId) {
  200. const messagesPath = getMessagesPath(extensionId)
  201. return readFile(messagesPath)
  202. })
  203. const validStorageTypes = new Set(['sync', 'local'])
  204. const getChromeStoragePath = (storageType, extensionId) => {
  205. if (!validStorageTypes.has(storageType)) {
  206. throw new Error(`Invalid storageType: ${storageType}`)
  207. }
  208. if (!manifestMap[extensionId]) {
  209. throw new Error(`Invalid extensionId: ${extensionId}`)
  210. }
  211. return path.join(app.getPath('userData'), `/Chrome Storage/${extensionId}-${storageType}.json`)
  212. }
  213. const mkdirp = util.promisify((dir, callback) => {
  214. fs.mkdir(dir, (error) => {
  215. if (error && error.code === 'ENOENT') {
  216. mkdirp(path.dirname(dir), (error) => {
  217. if (!error) {
  218. mkdirp(dir, callback)
  219. }
  220. })
  221. } else if (error && error.code === 'EEXIST') {
  222. callback(null)
  223. } else {
  224. callback(error)
  225. }
  226. })
  227. })
  228. ipcMainUtils.handle('CHROME_STORAGE_READ', async function (event, storageType, extensionId) {
  229. const filePath = getChromeStoragePath(storageType, extensionId)
  230. try {
  231. return await readFile(filePath, 'utf8')
  232. } catch (error) {
  233. if (error.code === 'ENOENT') {
  234. return null
  235. } else {
  236. throw error
  237. }
  238. }
  239. })
  240. ipcMainUtils.handle('CHROME_STORAGE_WRITE', async function (event, storageType, extensionId, data) {
  241. const filePath = getChromeStoragePath(storageType, extensionId)
  242. try {
  243. await mkdirp(path.dirname(filePath))
  244. } catch {
  245. // we just ignore the errors of mkdir or mkdirp
  246. }
  247. return writeFile(filePath, data, 'utf8')
  248. })
  249. const isChromeExtension = function (pageURL) {
  250. const { protocol } = url.parse(pageURL)
  251. return protocol === 'chrome-extension:'
  252. }
  253. const assertChromeExtension = function (contents, api) {
  254. const pageURL = contents._getURL()
  255. if (!isChromeExtension(pageURL)) {
  256. console.error(`Blocked ${pageURL} from calling ${api}`)
  257. throw new Error(`Blocked ${api}`)
  258. }
  259. }
  260. ipcMainUtils.handle('CHROME_TABS_EXECUTE_SCRIPT', async function (event, tabId, extensionId, details) {
  261. assertChromeExtension(event.sender, 'chrome.tabs.executeScript()')
  262. const contents = webContents.fromId(tabId)
  263. if (!contents) {
  264. throw new Error(`Sending message to unknown tab ${tabId}`)
  265. }
  266. let code, url
  267. if (details.file) {
  268. const manifest = manifestMap[extensionId]
  269. code = String(fs.readFileSync(path.join(manifest.srcDirectory, details.file)))
  270. url = `chrome-extension://${extensionId}${details.file}`
  271. } else {
  272. code = details.code
  273. url = `chrome-extension://${extensionId}/${String(Math.random()).substr(2, 8)}.js`
  274. }
  275. return ipcMainUtils.invokeInWebContents(contents, false, 'CHROME_TABS_EXECUTE_SCRIPT', extensionId, url, code)
  276. })
  277. // Transfer the content scripts to renderer.
  278. const contentScripts = {}
  279. const injectContentScripts = function (manifest) {
  280. if (contentScripts[manifest.name] || !manifest.content_scripts) return
  281. const readArrayOfFiles = function (relativePath) {
  282. return {
  283. url: `chrome-extension://${manifest.extensionId}/${relativePath}`,
  284. code: String(fs.readFileSync(path.join(manifest.srcDirectory, relativePath)))
  285. }
  286. }
  287. const contentScriptToEntry = function (script) {
  288. return {
  289. matches: script.matches,
  290. js: script.js ? script.js.map(readArrayOfFiles) : [],
  291. css: script.css ? script.css.map(readArrayOfFiles) : [],
  292. runAt: script.run_at || 'document_idle',
  293. allFrames: script.all_frames || false
  294. }
  295. }
  296. try {
  297. const entry = {
  298. extensionId: manifest.extensionId,
  299. contentScripts: manifest.content_scripts.map(contentScriptToEntry)
  300. }
  301. contentScripts[manifest.name] = renderProcessPreferences.addEntry(entry)
  302. } catch (e) {
  303. console.error('Failed to read content scripts', e)
  304. }
  305. }
  306. const removeContentScripts = function (manifest) {
  307. if (!contentScripts[manifest.name]) return
  308. renderProcessPreferences.removeEntry(contentScripts[manifest.name])
  309. delete contentScripts[manifest.name]
  310. }
  311. // Transfer the |manifest| to a format that can be recognized by the
  312. // |DevToolsAPI.addExtensions|.
  313. const manifestToExtensionInfo = function (manifest) {
  314. return {
  315. startPage: manifest.startPage,
  316. srcDirectory: manifest.srcDirectory,
  317. name: manifest.name,
  318. exposeExperimentalAPIs: true
  319. }
  320. }
  321. // Load the extensions for the window.
  322. const loadExtension = function (manifest) {
  323. startBackgroundPages(manifest)
  324. injectContentScripts(manifest)
  325. }
  326. const loadDevToolsExtensions = function (win, manifests) {
  327. if (!win.devToolsWebContents) return
  328. manifests.forEach(loadExtension)
  329. const extensionInfoArray = manifests.map(manifestToExtensionInfo)
  330. extensionInfoArray.forEach((extension) => {
  331. win.devToolsWebContents._grantOriginAccess(extension.startPage)
  332. })
  333. win.devToolsWebContents.executeJavaScript(`InspectorFrontendAPI.addExtensions(${JSON.stringify(extensionInfoArray)})`)
  334. }
  335. app.on('web-contents-created', function (event, webContents) {
  336. if (!isWindowOrWebView(webContents)) return
  337. hookWebContentsEvents(webContents)
  338. webContents.on('devtools-opened', function () {
  339. loadDevToolsExtensions(webContents, Object.values(manifestMap))
  340. })
  341. })
  342. // The chrome-extension: can map a extension URL request to real file path.
  343. const chromeExtensionHandler = function (request, callback) {
  344. const parsed = url.parse(request.url)
  345. if (!parsed.hostname || !parsed.path) return callback()
  346. const manifest = manifestMap[parsed.hostname]
  347. if (!manifest) return callback()
  348. const page = backgroundPages[parsed.hostname]
  349. if (page && parsed.path === `/${page.name}`) {
  350. // Disabled due to false positive in StandardJS
  351. // eslint-disable-next-line standard/no-callback-literal
  352. return callback({
  353. mimeType: 'text/html',
  354. data: page.html
  355. })
  356. }
  357. fs.readFile(path.join(manifest.srcDirectory, parsed.path), function (err, content) {
  358. if (err) {
  359. // Disabled due to false positive in StandardJS
  360. // eslint-disable-next-line standard/no-callback-literal
  361. return callback(-6) // FILE_NOT_FOUND
  362. } else {
  363. return callback(content)
  364. }
  365. })
  366. }
  367. app.on('session-created', function (ses) {
  368. ses.protocol.registerBufferProtocol('chrome-extension', chromeExtensionHandler, function (error) {
  369. if (error) {
  370. console.error(`Unable to register chrome-extension protocol: ${error}`)
  371. }
  372. })
  373. })
  374. // The persistent path of "DevTools Extensions" preference file.
  375. let loadedDevToolsExtensionsPath = null
  376. app.on('will-quit', function () {
  377. try {
  378. const loadedDevToolsExtensions = Array.from(devToolsExtensionNames)
  379. .map(name => manifestNameMap[name].srcDirectory)
  380. if (loadedDevToolsExtensions.length > 0) {
  381. try {
  382. fs.mkdirSync(path.dirname(loadedDevToolsExtensionsPath))
  383. } catch {
  384. // Ignore error
  385. }
  386. fs.writeFileSync(loadedDevToolsExtensionsPath, JSON.stringify(loadedDevToolsExtensions))
  387. } else {
  388. fs.unlinkSync(loadedDevToolsExtensionsPath)
  389. }
  390. } catch {
  391. // Ignore error
  392. }
  393. })
  394. // We can not use protocol or BrowserWindow until app is ready.
  395. app.once('ready', function () {
  396. // The public API to add/remove extensions.
  397. BrowserWindow.addExtension = function (srcDirectory) {
  398. const manifest = getManifestFromPath(srcDirectory)
  399. if (manifest) {
  400. loadExtension(manifest)
  401. for (const webContents of getAllWebContents()) {
  402. if (isWindowOrWebView(webContents)) {
  403. loadDevToolsExtensions(webContents, [manifest])
  404. }
  405. }
  406. return manifest.name
  407. }
  408. }
  409. BrowserWindow.removeExtension = function (name) {
  410. const manifest = manifestNameMap[name]
  411. if (!manifest) return
  412. removeBackgroundPages(manifest)
  413. removeContentScripts(manifest)
  414. delete manifestMap[manifest.extensionId]
  415. delete manifestNameMap[name]
  416. }
  417. BrowserWindow.getExtensions = function () {
  418. const extensions = {}
  419. Object.keys(manifestNameMap).forEach(function (name) {
  420. const manifest = manifestNameMap[name]
  421. extensions[name] = { name: manifest.name, version: manifest.version }
  422. })
  423. return extensions
  424. }
  425. BrowserWindow.addDevToolsExtension = function (srcDirectory) {
  426. const manifestName = BrowserWindow.addExtension(srcDirectory)
  427. if (manifestName) {
  428. devToolsExtensionNames.add(manifestName)
  429. }
  430. return manifestName
  431. }
  432. BrowserWindow.removeDevToolsExtension = function (name) {
  433. BrowserWindow.removeExtension(name)
  434. devToolsExtensionNames.delete(name)
  435. }
  436. BrowserWindow.getDevToolsExtensions = function () {
  437. const extensions = BrowserWindow.getExtensions()
  438. const devExtensions = {}
  439. Array.from(devToolsExtensionNames).forEach(function (name) {
  440. if (!extensions[name]) return
  441. devExtensions[name] = extensions[name]
  442. })
  443. return devExtensions
  444. }
  445. // Load persisted extensions.
  446. loadedDevToolsExtensionsPath = path.join(app.getPath('userData'), 'DevTools Extensions')
  447. try {
  448. const loadedDevToolsExtensions = JSON.parse(fs.readFileSync(loadedDevToolsExtensionsPath))
  449. if (Array.isArray(loadedDevToolsExtensions)) {
  450. for (const srcDirectory of loadedDevToolsExtensions) {
  451. // Start background pages and set content scripts.
  452. BrowserWindow.addDevToolsExtension(srcDirectory)
  453. }
  454. }
  455. } catch (error) {
  456. if (process.env.ELECTRON_ENABLE_LOGGING && error.code !== 'ENOENT') {
  457. console.error('Failed to load browser extensions from directory:', loadedDevToolsExtensionsPath)
  458. console.error(error)
  459. }
  460. }
  461. })