three-devtools is a web extension that allows inspection of content in a three.js environment.using web components for the UI.
npm run build:deps
builds dependencies via @pika/webnpm run build:dist
builds a zip file of the extension in thedist/*
directory for non-Chrome browsers.npm run build:dist:chrome
builds a zip file of the extension in thedist/*
directory for Chrome. Chrome does not recognize the web extension manifest keybrowser_specific_settings
, and requires a special build to fix this warning.npm run build:source
builds a zip file of the source files for the extension (sans dependencies from npm) for AMO reviews in order to build the extension.
Using web-ext allows local development via Firefox. web-ext run
in the project directory.
In chrome://extensions
, load the extension unpacked.
A (benign) warning will appear referencing the browser_specific_settings
key in manifest.json
as unrecognized. Everything else works as expected.
Running npm run build:dist
creates a zip of the extension in ./dist
.
Running locally as an unsigned extension on Firefox requires Developer Edition, Nightly or ESR and xpinstall.signatures.required
in about:config
(source).
Chrome requires its own build since it does not recognize browser_specific_settings
in the web extension manifest. Run npm run build:dist:chrome
to create a build specifically for Chrome in ./dist
. This will then need to be packed as a .crx
via "Pack Extension" in chrome://extensions
.
Before publishing:
- Test Firefox via
web-ext run
- Test Chrome via unpacked extension in
chrome://extensions
- Increment the version via
npm version [major | minor | patch]
. This will update the version acrosspackage.json
,manifest.json
and tag and push to the git origin.
npm run build:dist
andnpm run build:source
- Go to Three.js Developer Tools AMO Page and upload build
dist/three.js_developer_tools_*.zip
- Since @pika/web is used to bundle dependencies, according to AMO's source code submission policy, source code also needs to be uploaded. Upload
dist/three-devtools-source.zip
- TODO Should add a markdown page for AMO reviewers to easily recreate builds
- Since @pika/web is used to bundle dependencies, according to AMO's source code submission policy, source code also needs to be uploaded. Upload
- Ensure minimum Firefox version is updated -- latest release version (or maybe ESR?).
- Build extension for Chrome:
npm run build:dist:chrome
- Go to Chrome's Developer Dashboard and edit the Three.js Developer Tools entry, and upload
dist/three.js_developer_tools_*.zip
- Accept the review notice that it'll take some time due to the broad permissions.
Devtools extensions run in several different processes and communicating between content, background scripts and devtools pages requires some plumbing. See Extending DevTools for more details.
The UI is created using web components, a suite of technologies including custom elements, shadow DOM, and templates. The custom elements are use LitElement to provide a React-ish way of defining elements. There are some caveats and roadbumps that devs can encounter if new to web components; some resources below that may be useful:
- src/app/: The frontend code for the UI inside of devtools.
- src/extension/: Scripts for handling the communication plumbing for the devtools, background and content script processes.
- src/content/: Scripts that are evaluated within user-script context, and will have access to the relevant three.js instances. Note currently this content is injected in a content script context as a string, and later injected due to extension injected bug (#1).
- src/shared/: Scripts that are shared between contexts.
- web_modules/: Built module dependencies.
Data from a three.js scene must travel from the injected script, (src/content/), to the content script context (src/extension/contentScript.js), then to the background script (src/extension/background.js), which then sends the message to the corresponding devtools context for that inspected window if it exists (src/extension/devtools.js), and then to its corresponding iframe, running the frontend app (src/app/index.html).
To complete the cycle, the frontend app directly evaluates into the user script context, interacting with the injected ThreeDevTools
singleton's private methods.
User-land code can register the inspected scene and renderer via the singleton API:
if (window.ThreeDevTools) {
window.ThreeDevTools.connect({ scene, renderer });
}
Currently the content script is included on every page, which then injects window.ThreeDevTools
synchronously into the user content context so that content can register itself.
The UI is constructed using web components with LitElement. LitElements have a defined lifecycle and pass attributes/properties to their children elements, similar to React components. The root element (currently <three-devtools-app />
) manages the state of the application: some subset of the content's three.js state, any UI state, and any extension preferences. As the state changes, the attributes flow down the tree via LitElement's rendering.
To make a state change from the UI, events can be fired ("commands") where they're processed by the root element.
The end result is that changes from content or from events are handled by the root element, which may cause a rerender, changes propagating downward through the DOM tree, similar to using React with Redux communication.
Note that due to using UUID attributes and using that to reference the store to get the latest information of an entity, an element can force rerender even though attributes have not changed to get the latest data for an entity.
There's BaseElement
that many components inherit from. This offers a uuid
attribute, which when set, will rerender the element whenever it detected a state change for the entity with that UUID. This is managed through the root element, but each BaseElement also can handle its own update, deviating a bit from the above data flow (although state still managed by the root, elements subcribing to a subset of that state). Maybe this circumvention would be less of an issue if I more consistently used properties rather than attributes in LitElement.
- View Elements are the larger panels inside the tool. Probably should inherit from the same base at some point.
- Value Elements (src/app/elements/values/) are input components that can be used in the GUI to represent different data types like an integer with a slider, or a material with a small preview inside of another view to be edited.
entity
is used to refer to any type of three.js instance: object, material, texture, etc. (due to "object" being a specific type)typeHint
is used to mean what category type of an entity, with an unfortunate name (due to "type" referring to class name in three.js). This is so components can respond differently to differently typed entities. In the future, could be a utility perhaps rather than storing on the data object itself. CurrenttypeHint
s:- 'object'
- 'material'
- 'texture'
- 'image'
- 'geometry'
- 'shape'
- Clone this repo.
$ git clone [email protected]:jsantell/three-devtools.git
$ cd three-devtools
$ npm install
- Install the extension as an unpacked extension.
- The extension should now run
- Developer tools can be called for the extension panel itself via right click -> inspect. If making changes only to
src/app
code, refreshing this panel is sufficient. - Changing code in
./src/content/*
and./src/extension/*
require refreshing the extension (e.g. inchrome://extensions
)
three-devtools is using @pika/web for building dependencies, handling the dependency graph into an ES module, resulting in needing no "build" between iterations. Call npm run build
if updating the dependencies and regenerating web_modules/
.
Serialized communication between user-land content (e.g. three.js) and a panel in the developer tools must jump through 4 different contexts (injected/user, content script, background, devtools/app) due to the architecture of extensions themselves. This certainly makes things more complex, and there does not yet exist a good abstraction around this that I've found. Luckily, the boilerplate-esque communication code will have a low frequency of change.
There's an alternative form of communication, using the devtools' ability to evaluate in the inspected window's user context, enabling the possibility of polling globals from devtools. Infact, this is how the devtools panel communicates with the injected code:
chrome.devtools.inspectedWindow.eval(`ThreeDevTools.METHOD(${value})`);
The commands are simple, small values, indicating a value change or a request. The reverse direction (content to devtools) must handle large textures as base64 strings, which incurs significant jank when pulling via eval
polling, which is why the more complex port messaging system is used.
The Devtools -> Content communication could also use port messaging in the future; eval
was chosen purely for development speed, and since large data does not currently travel in this direction.
Content scripts have access to the DOM, but cannot access any globals from user-land script (e.g. code from script elements on a page). In order to access the three.js entities on the page, an additional script needs to be injected as if it were regular user-land script, by literally injecting a <script>
element with our injection code.
There are a few options to handle this.
- Synchronous injection at document_start
- This is the current implementation. Non-ideal as this is evaluated on every page load, and injecting content on every page load. This is what redux-devtools does, with a rather large content script. The challenge here is defining some object in the user context immediately (
window.ThreeDevTools
) so that content can access it, even if it's defined in the<head>
. - I attempted a system where opening devtools lands a cookie allowing that domain to inject content for the next few minutes, as cookies can be checked synchronously to determine if injection is needed. Was flaky, but due to extension confusion I think.
- The global indicates that the extension is installed, not open, if that matters to the content.
- Need to set text content rather than src of injection script to guarantee sync inject (#1).
- This is the current implementation. Non-ideal as this is evaluated on every page load, and injecting content on every page load. This is what redux-devtools does, with a rather large content script. The challenge here is defining some object in the user context immediately (
- Port messaging
- Something like
window.postMessage({ type: 'three-devtools-connect' })
that doesn't need to exist in global space before content runs, so synchronous code injection no longer needed. - Could have a purely
postMessage
API and no global. - Less inspectable than a singleton
- Moves code from injected code to content script code -- could the extra content script be lazily brought in on first sign of a post message?
- Something like