Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add offscreen user media sample #1056

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
34 changes: 34 additions & 0 deletions functional-samples/cookbook.offscreen-user-media/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
This recipe shows how to use User Media in an Extension Service Worker using the [Offscreen document][1].

## Context

- The extension aims to capture audio from the user in the context of extension (globally) via a media recorder in the offscreen document.
- Service worker no longer have access to window objects and APIs. Hence, it was difficult for the extension to fetch permissions and capture audio.
- Offscreen document handles permission checks, audio devices management and recording using navigator API and media recorder respectively.

## Steps

1. User presses START/STOP recording from extension popup
2. Popup sends message to background service worker.
3. If STOP, Service worker sends message to offscreen to stop mediarecorder.
4. If START, Service worker sends message to the active tab's content script to intiate recording process.
5. The content script sends message to offscreen to check audio permissions.
- If GRANTED, send message to offscreen to start media recorder.
- If DENIED, show alert on window
- If PROMPT,
- inject an IFrame to request permission from the user.
- Listen to the user'e response on the iFrame
- If allowed, move to GRANTED step. Else, DENIED.

## Running this extension

1. Clone this repository.
2. Load this directory in Chrome as an [unpacked extension][2].
3. Open the Extension menu and click the extension named "Offscreen API - User media".

Click on the extension popup for START and STOP recording buttons.

Inspect the offscreen html page to view logs from media recorder and audio chunk management.

[1]: https://developer.chrome.com/docs/extensions/reference/offscreen/
[2]: https://developer.chrome.com/docs/extensions/mv3/getstarted/development-basics/#load-unpacked
133 changes: 133 additions & 0 deletions functional-samples/cookbook.offscreen-user-media/background.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Path to the offscreen HTML document.
* @type {string}
*/
const OFFSCREEN_DOCUMENT_PATH = 'offscreen/offscreen.html';

/**
* Reason for creating the offscreen document.
* @type {string}
*/
const OFFSCREEN_REASON = 'USER_MEDIA';

/**
* Listener for extension installation.
*/
chrome.runtime.onInstalled.addListener(handleInstall);

/**
* Listener for messages from the extension.
* @param {Object} request - The message request.
* @param {Object} sender - The sender of the message.
* @param {function} sendResponse - Callback function to send a response.
*/
chrome.runtime.onMessage.addListener((request) => {
switch (request.message.type) {
case 'TOGGLE_RECORDING':
switch (request.message.data) {
case 'START':
initateRecordingStart();
break;
case 'STOP':
initateRecordingStop();
break;
}
break;
}
});

/**
* Handles the installation of the extension.
*/
async function handleInstall() {
console.log('Extension installed...');
if (!(await hasDocument())) {
// create offscreen document
await createOffscreenDocument();
}
}

/**
* Sends a message to the offscreen document.
* @param {string} type - The type of the message.
* @param {Object} data - The data to be sent with the message.
*/
async function sendMessageToOffscreenDocument(type, data) {
// Create an offscreen document if one doesn't exist yet
try {
if (!(await hasDocument())) {
await createOffscreenDocument();
}
} finally {
// Now that we have an offscreen document, we can dispatch the message.
chrome.runtime.sendMessage({
message: {
type: type,
target: 'offscreen',
data: data
}
});
}
}

/**
* Initiates the stop recording process.
*/
function initateRecordingStop() {
console.log('Recording stopped at offscreen');
sendMessageToOffscreenDocument('STOP_OFFSCREEN_RECORDING');
}

/**
* Initiates the start recording process.
*/
function initateRecordingStart() {
chrome.tabs.query({ active: true, lastFocusedWindow: true }, ([tab]) => {
if (chrome.runtime.lastError || !tab) {
console.error('No valid webpage or tab opened');
return;
}

chrome.tabs.sendMessage(
tab.id,
{
// Send message to content script of the specific tab to check and/or prompt mic permissions
message: { type: 'PROMPT_MICROPHONE_PERMISSION' }
},
(response) => {
// If user allows the mic permissions, we continue the recording procedure.
if (response.message.status === 'success') {
console.log('Recording started at offscreen');
sendMessageToOffscreenDocument('START_OFFSCREEN_RECORDING');
}
}
);
});
}

/**
* Checks if there is an offscreen document.
* @returns {Promise<boolean>} - Promise that resolves to a boolean indicating if an offscreen document exists.
*/
async function hasDocument() {
// Check all windows controlled by the service worker if one of them is the offscreen document
const matchedClients = await clients.matchAll();
for (const client of matchedClients) {
if (client.url.endsWith(OFFSCREEN_DOCUMENT_PATH)) {
return true;
}
}
return false;
}

/**
* Creates the offscreen document.
* @returns {Promise<void>} - Promise that resolves when the offscreen document is created.
*/
async function createOffscreenDocument() {
await chrome.offscreen.createDocument({
url: OFFSCREEN_DOCUMENT_PATH,
reasons: [OFFSCREEN_REASON],
justification: 'To interact with user media'
});
}
79 changes: 79 additions & 0 deletions functional-samples/cookbook.offscreen-user-media/contentScript.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Listener for messages from the background script.
* @param {Object} request - The message request.
* @param {Object} sender - The sender of the message.
* @param {function} sendResponse - Callback function to send a response.
* @returns {boolean} - Whether the response should be sent asynchronously (true by default).
*/
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
switch (request.message.type) {
case 'PROMPT_MICROPHONE_PERMISSION':
// Check for mic permissions. If not found, prompt
checkMicPermissions()
.then(() => {
sendResponse({ message: { status: 'success' } });
})
.catch(() => {
promptMicPermissions();
const iframe = document.getElementById('PERMISSION_IFRAME_ID');
window.addEventListener('message', (event) => {
if (event.source === iframe.contentWindow && event.data) {
if (event.data.type === 'permissionsGranted') {
sendResponse({
message: { status: 'success' }
});
} else {
sendResponse({
message: {
status: 'failure'
}
});
}
document.body.removeChild(iframe);
}
});
});
break;

default:
// Do nothing for other message types
break;
}
return true;
});

/**
* Checks microphone permissions using a message to the background script.
* @returns {Promise<void>} - Promise that resolves if permissions are granted, rejects otherwise.
*/
function checkMicPermissions() {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(
{
message: {
type: 'CHECK_PERMISSIONS',
target: 'offscreen'
}
},
(response) => {
if (response.message.status === 'success') {
resolve();
} else {
reject(response.message.data);
}
}
);
});
}

/**
* Prompts the user for microphone permissions using an iframe.
*/
function promptMicPermissions() {
const iframe = document.createElement('iframe');
iframe.setAttribute('hidden', 'hidden');
iframe.setAttribute('allow', 'microphone');
iframe.setAttribute('id', 'PERMISSION_IFRAME_ID');
iframe.src = chrome.runtime.getURL('requestPermissions.html');
document.body.appendChild(iframe);
}
25 changes: 25 additions & 0 deletions functional-samples/cookbook.offscreen-user-media/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "Offscreen API - User media",
"version": "1.0",
"manifest_version": 3,
"description": "Shows how to record audio in a chrome extension via offscreen document.",
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["contentScript.js"]
Copy link

@sorokinvj sorokinvj May 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can do just this

Suggested change
"js": ["contentScript.js"]
"js": ["contentScript.js", "requestPermissions.js"]

and run it without all the fuss with iframes, messaging to offscreen and back

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

however I still get permission in prompt state, so I am probably missing something from your code

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sorokinvj

#821 : The reason for requesting mic and cam permissions inside the iframe is because we want to ask for the permissions on behalf of the extension app (eg -> "[myExtension] wants to Use" instead of "www.google.com wants to use your camera and microphone") and hold onto the permissions across all tabs, only asking once for the permissions.


Your suggestion of injecting CS directly via manifest will not help in accomplishing above goal. But will definitely ease around the fuss if you don't need it in the context of your extension.

Attaching a snapshot for reference how your suggested code will ask for permissions.

image

Let me know if I am missing out something in your proposed flow.
Thanks.

}
],
"action": {
"default_popup": "popup/popup.html"
},
"permissions": ["offscreen", "activeTab", "tabs"],
"web_accessible_resources": [
{
"resources": ["requestPermissions.html", "requestPermissions.js"],
"matches": ["<all_urls>"]
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Offscreen Document</title>
<script src="./offscreen.js"></script>
</head>
<body></body>
</html>