Skip to content

Commit a88e9fe

Browse files
committed
feat: add crx protocol for resolving extension icons
1 parent f7d1cc6 commit a88e9fe

File tree

4 files changed

+158
-28
lines changed

4 files changed

+158
-28
lines changed

packages/electron-chrome-extensions/spec/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ protocol.registerSchemesAsPrivileged([
6363
{ scheme: 'stream', privileges: { standard: true, stream: true } },
6464
{ scheme: 'foo', privileges: { standard: true } },
6565
{ scheme: 'bar', privileges: { standard: true } },
66+
{ scheme: 'crx', privileges: { bypassCSP: true } },
6667
])
6768

6869
const cleanupTestSessions = async () => {

packages/electron-chrome-extensions/src/browser-action.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -190,11 +190,10 @@ export const injectBrowserAction = () => {
190190

191191
this.title = typeof info.title === 'string' ? info.title : ''
192192

193-
if (info.imageData) {
194-
this.style.backgroundImage = info.imageData ? `url(${info.imageData['32']})` : ''
195-
} else if (info.icon) {
196-
this.style.backgroundImage = `url(${info.icon})`
197-
}
193+
const iconSize = 32
194+
const resizeType = 2
195+
const iconUrl = `crx://extension-icon/${this.id}/${iconSize}/${resizeType}?tabId=${activeTabId}`
196+
this.style.backgroundImage = `url(${iconUrl})`
198197

199198
if (info.text) {
200199
const badge = this.getBadge()

packages/electron-chrome-extensions/src/browser/api/browser-action.ts

Lines changed: 125 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
1-
import { Menu, MenuItem } from 'electron'
1+
import { Menu, MenuItem, protocol, nativeImage, app } from 'electron'
22
import { ExtensionContext } from '../context'
33
import { PopupView } from '../popup'
44
import { ExtensionEvent } from '../router'
5-
import { getIconImage, getExtensionUrl, getExtensionManifest } from './common'
5+
import {
6+
getExtensionUrl,
7+
getExtensionManifest,
8+
getIconPath,
9+
resolveExtensionPath,
10+
matchSize,
11+
ResizeType,
12+
} from './common'
613

714
const debug = require('debug')('electron-chrome-extensions:browserAction')
815

16+
if (!app.isReady()) {
17+
protocol.registerSchemesAsPrivileged([{ scheme: 'crx', privileges: { bypassCSP: true } }])
18+
}
19+
920
interface ExtensionAction {
1021
color?: string
1122
text?: string
1223
title?: string
13-
icon?:
14-
| string
15-
| {
16-
path: string
17-
}
24+
icon?: chrome.browserAction.TabIconDetails
1825
popup?: string
1926
}
2027

@@ -35,8 +42,8 @@ const getBrowserActionDefaults = (extension: Electron.Extension): ExtensionActio
3542

3643
action.title = browser_action.default_title || manifest.name
3744

38-
const iconImage = getIconImage(extension)
39-
if (iconImage) action.icon = iconImage.toDataURL()
45+
const iconPath = getIconPath(extension)
46+
if (iconPath) action.icon = { path: iconPath }
4047

4148
if (browser_action.default_popup) {
4249
action.popup = browser_action.default_popup
@@ -114,7 +121,18 @@ export class BrowserActionAPI {
114121
handleProp('BadgeText', 'text')
115122
handleProp('Title', 'title')
116123
handleProp('Popup', 'popup')
117-
handle('browserAction.setIcon', setter('icon'))
124+
125+
const iconSetter = setter('icon')
126+
127+
// setIcon is unique in that it can pass in a variety of properties. Here we normalize them
128+
// to use 'icon'.
129+
handle(
130+
'browserAction.setIcon',
131+
(event, { tabId, ...details }: chrome.browserAction.TabIconDetails) => {
132+
const iconDetails = { tabId, icon: details }
133+
iconSetter(event, iconDetails)
134+
}
135+
)
118136

119137
// browserAction preload API
120138
const preloadOpts = { allowRemote: true, extensionContext: false }
@@ -151,6 +169,7 @@ export class BrowserActionAPI {
151169
delete actionDetails.tabs[tabId]
152170
}
153171
}
172+
this.onUpdate()
154173
})
155174

156175
this.setupSession(this.ctx.session)
@@ -164,6 +183,83 @@ export class BrowserActionAPI {
164183
session.on('extension-unloaded', (event, extension) => {
165184
this.removeActions(extension.id)
166185
})
186+
187+
session.protocol.registerBufferProtocol('crx', this.handleCrxRequest)
188+
}
189+
190+
private handleCrxRequest = (
191+
request: Electron.ProtocolRequest,
192+
callback: (response: Electron.ProtocolResponse) => void
193+
) => {
194+
debug('%s', request.url)
195+
196+
let response: Electron.ProtocolResponse
197+
198+
try {
199+
const url = new URL(request.url)
200+
const { hostname: requestType } = url
201+
202+
switch (requestType) {
203+
case 'extension-icon': {
204+
const tabId = url.searchParams.get('tabId')
205+
206+
const fragments = url.pathname.split('/')
207+
const extensionId = fragments[1]
208+
const imageSize = parseInt(fragments[2], 10)
209+
const resizeType = parseInt(fragments[3], 10) || ResizeType.Up
210+
211+
const extension = this.ctx.session.getExtension(extensionId)
212+
213+
let iconDetails: chrome.browserAction.TabIconDetails | undefined
214+
215+
const action = this.actionMap.get(extensionId)
216+
if (action) {
217+
iconDetails = (tabId && action.tabs[tabId]?.icon) || action.icon
218+
}
219+
220+
let iconImage
221+
222+
if (extension && iconDetails) {
223+
if (typeof iconDetails.path === 'string') {
224+
const iconAbsPath = resolveExtensionPath(extension, iconDetails.path)
225+
if (iconAbsPath) iconImage = nativeImage.createFromPath(iconAbsPath)
226+
} else if (typeof iconDetails.path === 'object') {
227+
const imagePath = matchSize(iconDetails.path, imageSize, resizeType)
228+
const iconAbsPath = imagePath && resolveExtensionPath(extension, imagePath)
229+
if (iconAbsPath) iconImage = nativeImage.createFromPath(iconAbsPath)
230+
} else if (typeof iconDetails.imageData === 'string') {
231+
iconImage = nativeImage.createFromDataURL(iconDetails.imageData)
232+
} else if (typeof iconDetails.imageData === 'object') {
233+
const imageData = matchSize(iconDetails.imageData as any, imageSize, resizeType)
234+
iconImage = imageData ? nativeImage.createFromDataURL(imageData) : undefined
235+
}
236+
}
237+
238+
if (iconImage) {
239+
response = {
240+
statusCode: 200,
241+
mimeType: 'image/png',
242+
data: iconImage.toPNG(),
243+
}
244+
} else {
245+
response = { statusCode: 400 }
246+
}
247+
248+
break
249+
}
250+
default: {
251+
response = { statusCode: 400 }
252+
}
253+
}
254+
} catch (e) {
255+
console.error(e)
256+
257+
response = {
258+
statusCode: 500,
259+
}
260+
}
261+
262+
callback(response)
167263
}
168264

169265
private getAction(extensionId: string) {
@@ -201,11 +297,25 @@ export class BrowserActionAPI {
201297
}
202298
}
203299

204-
private getState(event: ExtensionEvent) {
205-
const actions = Array.from(this.actionMap.entries()).map((val: any) => ({
206-
id: val[0],
207-
...val[1],
208-
}))
300+
private getState() {
301+
// Get state without icon data.
302+
const actions = Array.from(this.actionMap.entries()).map(([id, details]) => {
303+
const { icon, tabs, ...rest } = details
304+
305+
const tabsInfo: { [key: string]: any } = {}
306+
307+
for (const tabId of Object.keys(tabs)) {
308+
const { icon, ...rest } = tabs[tabId]
309+
tabsInfo[tabId] = rest
310+
}
311+
312+
return {
313+
id,
314+
tabs: tabsInfo,
315+
...rest,
316+
}
317+
})
318+
209319
const activeTab = this.ctx.store.getActiveTabOfCurrentWindow()
210320
return { activeTabId: activeTab?.id, actions }
211321
}

packages/electron-chrome-extensions/src/browser/api/common.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export const getExtensionUrl = (extension: Electron.Extension, uri: string) => {
3636
} catch {}
3737
}
3838

39-
const resolveExtensionPath = (extension: Electron.Extension, uri: string) => {
39+
export const resolveExtensionPath = (extension: Electron.Extension, uri: string) => {
4040
const resPath = path.join(extension.path, uri)
4141

4242
// prevent any parent traversals
@@ -58,27 +58,47 @@ export const validateExtensionResource = async (extension: Electron.Extension, u
5858
return resPath
5959
}
6060

61-
export const getIconPath = (extension: Electron.Extension) => {
61+
export enum ResizeType {
62+
Exact,
63+
Up,
64+
Down,
65+
}
66+
67+
export const matchSize = (
68+
imageSet: { [key: number]: string },
69+
size: number,
70+
match: ResizeType
71+
): string | undefined => {
72+
// TODO: match based on size
73+
const first = parseInt(Object.keys(imageSet).pop()!, 10)
74+
return imageSet[first]
75+
}
76+
77+
/** Gets the relative path to the extension's default icon. */
78+
export const getIconPath = (
79+
extension: Electron.Extension,
80+
iconSize: number = 32,
81+
resizeType = ResizeType.Up
82+
) => {
6283
const { browser_action, icons } = getExtensionManifest(extension)
6384
const { default_icon } = browser_action || {}
6485

6586
if (typeof default_icon === 'string') {
66-
const iconPath = path.join(extension.path, default_icon)
87+
const iconPath = default_icon
6788
return iconPath
6889
} else if (typeof default_icon === 'object') {
69-
const key = Object.keys(default_icon).pop() as any
70-
const iconPath = path.join(extension.path, default_icon[key])
90+
const iconPath = matchSize(default_icon, iconSize, resizeType)
7191
return iconPath
7292
} else if (typeof icons === 'object') {
73-
const key = Object.keys(icons).pop() as any
74-
const iconPath = path.join(extension.path, icons[key])
93+
const iconPath = matchSize(icons, iconSize, resizeType)
7594
return iconPath
7695
}
7796
}
7897

7998
export const getIconImage = (extension: Electron.Extension) => {
8099
const iconPath = getIconPath(extension)
81-
return iconPath ? nativeImage.createFromPath(iconPath) : undefined
100+
const iconAbsolutePath = iconPath && resolveExtensionPath(extension, iconPath)
101+
return iconAbsolutePath ? nativeImage.createFromPath(iconAbsolutePath) : undefined
82102
}
83103

84104
const escapePattern = (pattern: string) => pattern.replace(/[\\^$+?.()|[\]{}]/g, '\\$&')

0 commit comments

Comments
 (0)