diff --git a/manifest.js b/manifest.js index 8fd5384..5622848 100755 --- a/manifest.js +++ b/manifest.js @@ -54,6 +54,8 @@ const manifest = { "assets/fonts/*", "icon-128.png", "icon-34.png", + "src/pages/permission/index.html", + "src/pages/permission/requestPermissions.ts", ], matches: ["*://*/*"], }, diff --git a/src/common/Settings.tsx b/src/common/Settings.tsx index fbffe88..ba4caa0 100644 --- a/src/common/Settings.tsx +++ b/src/common/Settings.tsx @@ -15,11 +15,13 @@ import { StackDivider, Flex, Spacer, + useToast, } from "@chakra-ui/react"; import { ArrowBackIcon, RepeatIcon } from "@chakra-ui/icons"; import { useAppState } from "../state/store"; import React from "react"; import ModelDropdown from "./ModelDropdown"; +import { callRPC } from "../helpers/rpc/pageRPC"; interface SettingsProps { setInSettingsView: React.Dispatch>; @@ -32,6 +34,7 @@ const Settings = ({ setInSettingsView }: SettingsProps) => { voiceMode: state.settings.voiceMode, openAIKey: state.settings.openAIKey, })); + const toast = useToast(); if (!state.openAIKey) return null; @@ -39,6 +42,42 @@ const Settings = ({ setInSettingsView }: SettingsProps) => { const closeSetting = () => setInSettingsView(false); + async function checkMicrophonePermission(): Promise { + if (!navigator.permissions) { + return "prompt"; + } + try { + const permission = await navigator.permissions.query({ + name: "microphone" as PermissionName, + }); + return permission.state; + } catch (error) { + console.error("Error checking microphone permission:", error); + return "denied"; + } + } + + const handleVoiceMode = async (isEnabled: boolean) => { + if (isEnabled) { + const permissionState = await checkMicrophonePermission(); + if (permissionState === "denied") { + toast({ + title: "Error", + description: + "Microphone access was previously blocked. Please enable it in your browser settings.", + status: "error", + duration: 5000, + isClosable: true, + }); + return; + } else if (permissionState === "prompt") { + callRPC("injectMicrophonePermissionIframe", []).catch(console.error); + } else if (permissionState === "granted") { + console.log("Microphone permission granted"); + } + } + }; + return ( <> @@ -104,9 +143,11 @@ const Settings = ({ setInSettingsView }: SettingsProps) => { - state.updateSettings({ voiceMode: e.target.checked }) - } + onChange={(e) => { + const isEnabled = e.target.checked; + handleVoiceMode(isEnabled); + state.updateSettings({ voiceMode: isEnabled }); + }} /> diff --git a/src/helpers/voiceControl.ts b/src/helpers/voiceControl.ts index fbb3e70..c3ac9f1 100644 --- a/src/helpers/voiceControl.ts +++ b/src/helpers/voiceControl.ts @@ -44,7 +44,7 @@ class VoiceControlManager { } } - public startListening = (): void => { + public startListening = async (): Promise => { if (!this.recognition) { console.error("Speech Recognition is not initialized."); return; diff --git a/src/pages/content/domOperations.ts b/src/pages/content/domOperations.ts index c95b2af..00758a6 100644 --- a/src/pages/content/domOperations.ts +++ b/src/pages/content/domOperations.ts @@ -8,6 +8,7 @@ import { drawLabels, removeLabels } from "./drawLabels"; import ripple from "./ripple"; import { getDataFromRenderedMarkdown } from "./reverseMarkdown"; import getViewportPercentage from "./getViewportPercentage"; +import { injectMicrophonePermissionIframe } from "./permission"; export const rpcMethods = { getAnnotatedDOM, @@ -19,6 +20,7 @@ export const rpcMethods = { removeLabels, getDataFromRenderedMarkdown, getViewportPercentage, + injectMicrophonePermissionIframe, } as const; export type RPCMethods = typeof rpcMethods; diff --git a/src/pages/content/permission.ts b/src/pages/content/permission.ts new file mode 100644 index 0000000..3f489a2 --- /dev/null +++ b/src/pages/content/permission.ts @@ -0,0 +1,8 @@ +export const injectMicrophonePermissionIframe = () => { + const iframe = document.createElement("iframe"); + iframe.setAttribute("hidden", "hidden"); + iframe.setAttribute("id", "permissionsIFrame"); + iframe.setAttribute("allow", "microphone"); + iframe.src = chrome.runtime.getURL("/src/pages/permission/index.html"); + document.body.appendChild(iframe); +}; diff --git a/src/pages/permission/index.html b/src/pages/permission/index.html new file mode 100644 index 0000000..99fe68f --- /dev/null +++ b/src/pages/permission/index.html @@ -0,0 +1,10 @@ + + + + Request Permissions + + + + + + \ No newline at end of file diff --git a/src/pages/permission/requestPermission.ts b/src/pages/permission/requestPermission.ts new file mode 100644 index 0000000..5779892 --- /dev/null +++ b/src/pages/permission/requestPermission.ts @@ -0,0 +1,35 @@ +/** + * Requests user permission for microphone access. + * @returns {Promise} A Promise that resolves when permission is granted or rejects with an error. + */ +export async function getUserPermission(): Promise { + return new Promise((resolve, reject) => { + // Using navigator.mediaDevices.getUserMedia to request microphone access + navigator.mediaDevices + .getUserMedia({ audio: true }) + .then((stream) => { + // Permission granted, handle the stream if needed + console.log("Microphone access granted"); + + // Stop the tracks to prevent the recording indicator from being shown + stream.getTracks().forEach(function (track) { + track.stop(); + }); + + resolve(); + }) + .catch((error) => { + console.error("Error requesting microphone permission", error); + + // Handling different error scenarios + if (error.name === "Permission denied") { + reject("MICROPHONE_PERMISSION_DENIED"); + } else { + reject(error); + } + }); + }); +} + +// Call the function to request microphone permission +getUserPermission(); diff --git a/vite.config.ts b/vite.config.ts index ac5ac8c..7f05015 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -50,6 +50,7 @@ export default defineConfig({ content: resolve(pagesDir, "content", "index.ts"), contentStyleGlobal: resolve(pagesDir, "content", "style.global.scss"), contentStyle: resolve(pagesDir, "content", "style.scss"), + permission: resolve(pagesDir, "permission", "index.html"), // TODO: current cannot support multiple content script entry files // https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/issues/306#issuecomment-1981885190 // mainWorld: resolve(pagesDir, "content/mainWorld", "index.ts"),