Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@
/node_modules
npm-debug.log.**
npm-debug.log
.DS_Store
.DS_Store
/safari-xcode
73 changes: 73 additions & 0 deletions documentation/safari-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
[SpectorJS](../readme.md)
=========

## Safari Extension

Spector.js is available as a Safari Web Extension for macOS. Since Safari requires extensions to be wrapped in a native macOS app, the build process involves both npm and Xcode.

### Prerequisites

- macOS with Xcode installed (Xcode 14+)
- [Apple Developer account](https://developer.apple.com/) (required for distribution; not needed for local testing)
- Node.js and npm

### Building the Safari Extension

1. **Build the Spector.js bundle:**
```bash
npm install
npm run build
```
This compiles the core library and copies `spector.bundle.js` into `safari-extension/`.

2. **Generate the Xcode project** (first time only):
```bash
xcrun safari-web-extension-converter safari-extension/ \
--project-location safari-xcode \
--app-name "Spector.js" \
--bundle-identifier com.babylonjs.spectorjs \
--macos-only \
--no-open
```
This creates a macOS app project in `safari-xcode/` that wraps the web extension.

3. **Build and run from Xcode:**
- Open `safari-xcode/Spector.js/Spector.js.xcodeproj` in Xcode
- Select your development team under Signing & Capabilities
- Build and run (⌘R)

### Development Workflow

1. **Enable unsigned extensions** in Safari:
- Open Safari → Settings → Advanced → check "Show features for web developers"
- Then Safari → Settings → Developer → check "Allow unsigned extensions"

2. **Build and run** the Xcode project — Safari will load the extension automatically.

3. **Iterate on extension code:** After editing files in `safari-extension/`, rebuild in Xcode (⌘R). For changes to the core Spector library in `src/`, run `npm run build` first.

4. **Test** using the sample pages at `http://localhost:1337/sample/index.html` (run `npm start` for the dev server).

### Architecture Differences from Chrome/Firefox

The Safari extension uses a single content script in the default ISOLATED world (Safari does not support Manifest V3's `"world": "MAIN"`). To interact with page-level JavaScript:

- **getContext() interception** and **Spector initialization** are injected into the page via `<script>` tags at runtime
- **Communication** between the injected page code and the extension uses CustomEvents and hidden DOM elements (same pattern as Chrome, just with explicit script injection)
- **browser.runtime APIs** are only called from the ISOLATED world content script, which bridges messages to/from the background service worker

### Distribution

Safari extensions are distributed via the Mac App Store:

1. Archive the Xcode project (Product → Archive)
2. Upload to App Store Connect
3. Submit for review

Refer to [Apple's documentation](https://developer.apple.com/documentation/safariservices/safari-web-extensions) for detailed distribution guidelines.

### Known Limitations

- **macOS only** — iOS/iPadOS support is not currently included (WebGL debugging is primarily a desktop use case)
- **`file://` URLs** — Safari has stricter restrictions on extension access to local files compared to Chrome
- **Unsigned extensions** must be re-enabled each time Safari is relaunched during development
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@
"build:bundle": "webpack --config tools/webpack.config.js --env=prod",
"build:copybuild:copy:bundle": "node ./tools/copy.js /../dist/spector.bundle.js /../extensions/spector.bundle.js",
"build:concatBundleFunc": "concat-cli -f tools/spector.ext.header.js dist/spector.bundle.js tools/spector.ext.footer.js -o extensions/spector.bundle.func.js",
"build:safari:copy:bundle": "node ./tools/copy.js /../dist/spector.bundle.js /../safari-extension/spector.bundle.js",
"build:tslint": "tslint -c ./tslint.json -p ./src/tsconfig.json",
"build": "run-s build:tslint build:bundle build:copybuild:copy:bundle build:concatBundleFunc -n",
"build": "run-s build:tslint build:bundle build:copybuild:copy:bundle build:concatBundleFunc build:safari:copy:bundle -n",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
Expand Down
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ You can use the library inside of your own application easily as it is transpile
If you are willing to use the browser extension, you can direclty install it from the store:
- [Chrome](https://chrome.google.com/webstore/detail/spectorjs/denbgaamihkadbghdceggmchnflmhpmk)
- [Firefox](https://addons.mozilla.org/en-US/firefox/addon/spector-js/)
- Safari — see the [Safari extension documentation](documentation/safari-extension.md) for build and installation instructions

You can find on [Real Time Rendering](http://www.realtimerendering.com/blog/debugging-webgl-with-spectorjs/) a complete tutorial about the [Spector extension](http://www.realtimerendering.com/blog/debugging-webgl-with-spectorjs/). Else, you can refer to the [extension documentation](https://github.com/BabylonJS/Spector.js/blob/release/documentation/extension.md#how-to-use) to learn how to use it.

Expand Down
187 changes: 187 additions & 0 deletions safari-extension/background.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
//_______________________________EXTENSION POLYFILL_____________________________________
// Safari natively supports the browser.* namespace
var _browser = (typeof browser !== "undefined") ? browser : chrome;
if (typeof globalThis !== "undefined") {
globalThis.browser = _browser;
} else {
self.browser = _browser;
}

function sendMessage(message) {
_browser.tabs.query({ active: true, currentWindow: true }, function(tabs) {
_browser.tabs.sendMessage(tabs[0].id, message, function(response) { });
});
};

function sendRuntimeMessage(message) {
_browser.runtime.sendMessage(message, function(response) { });
};

function listenForMessage(callback) {
_browser.runtime.onMessage.addListener(callback);
};
//_____________________________________________________________________________________

//_______________________________ MAIN WORLD CONTENT SCRIPTS ___________________________
// Register pageScript.js to run in the MAIN world at document_start.
// This bypasses page CSP (unlike inline <script> injection) and ensures the
// getContext() patch is in place before any page scripts run.
_browser.scripting.registerContentScripts([{
id: "spector-page-script",
matches: ["http://*/*", "https://*/*"],
js: ["pageScript.js"],
runAt: "document_start",
allFrames: true,
world: "MAIN"
}]).catch(function() {
// Script may already be registered from a previous session
});
//_____________________________________________________________________________________

// Inject the Spector bundle and init script into a tab's MAIN world.
function injectSpectorBundle(tabId) {
_browser.scripting.executeScript({
target: { tabId: tabId, allFrames: true },
files: ["spector.bundle.js", "pageScriptInit.js"],
world: "MAIN"
}).catch(function(e) {
console.error("Spector: Failed to inject bundle", e);
});
}
//_____________________________________________________________________________________

var tabInfo = {}
var resultTab = null;
var currentCapture = null;
var currentFrameId = null;
var currentTabId = null;

var refreshCanvases = function() {
var canvasesToSend = { canvases: [], captureOffScreen: false };
_browser.tabs.query({ active: true, currentWindow: true }, function(tabs) {
for (var tabId in tabInfo) {
if (tabId == tabs[0].id) {
for (var frameId in tabInfo[tabId]) {
var infos = tabInfo[tabId][frameId];
canvasesToSend.captureOffScreen = infos.captureOffScreen;
for (var i = 0; i < infos.canvases.length; i++) {
var info = infos.canvases[i];
canvasesToSend.canvases.push({
id: info.id,
width: info.width,
height: info.height,
ref: { tabId: tabId, frameId: frameId, index: info.ref }
});
}
}
}
}

sendRuntimeMessage({
popup: "updateCanvasesListInformation",
data: canvasesToSend
});
});
}

_browser.action.onClicked.addListener(function (tab) {
sendMessage({ action: "pageAction" });
});

listenForMessage(function(request, sender, sendResponse) {
var frameId;
if (sender.frameId) {
frameId = sender.frameId;
}
else if (request.uniqueId) {
frameId = request.uniqueId;
}
else {
frameId = sender.id;
}
frameId += "";

if (request.action) {
}
// Content script requests bundle injection after page reload with Spector enabled
else if (request.injectSpector && sender.tab) {
injectSpectorBundle(sender.tab.id);
}
else if (request.present === 1) {
_browser.action.enable(sender.tab.id);
}
// In case we are enabled, change the icon to green and enable the popup.
else if (request.present === 2) {
_browser.action.setIcon({tabId: sender.tab.id, path: {
"19": "spectorjs-green-19.png",
"38": "spectorjs-green-38.png"
}});
_browser.action.setPopup({tabId: sender.tab.id, popup: "popup.html"});
_browser.action.enable(sender.tab.id);
}
else if (request.refreshCanvases) {
_browser.tabs.query({ active: true, currentWindow: true }, function(tabs) {
tabInfo = {}
sendMessage({ action: "requestCanvases" });

setTimeout(function() { refreshCanvases(); }, 500);
setTimeout(function() { refreshCanvases(); }, 2000);
});
}
else if (request.fps) {
sendRuntimeMessage({
popup: "refreshFps",
data: {
fps: request.fps,
frameId: frameId,
senderTabId: sender.tab.id
}
});
}
else if (request.canvases) {
// Store the list of found canvases for the caller frame.
var tabId = sender.tab.id + "";
if (!tabInfo[tabId]) {
tabInfo[tabId] = { };
}

tabInfo[tabId][frameId] = { canvases: request.canvases, captureOffScreen: request.captureOffScreen };
}
else if (request.errorString) {
// Close the wait message and may display an error.
sendRuntimeMessage({
popup: "captureComplete",
data: request.errorString
});
}
else if (request.captureDone) {
currentFrameId = frameId;
currentTabId = sender?.tab?.id;

_browser.storage.local.set({
"currentFrameInfo": {
currentFrameId,
currentTabId
}
});

// Open the result view.
_browser.tabs.create({ url: "result.html", active: true }, function(tab) {
resultTab = tab;
});

// Close the wait message and may display an error.
sendRuntimeMessage({
popup: "captureComplete"
});
}
else if (request.pageReload) {
// Display the fps of the selected frame.
sendRuntimeMessage({
popup: "refreshCanvases"
});
}

// Return the frameid for reference.
sendResponse({ frameId: frameId });
});
Loading