diff --git a/public/permissions.html b/public/permissions.html index 7a75142..d05bcf3 100644 --- a/public/permissions.html +++ b/public/permissions.html @@ -1,11 +1,14 @@ - + + + klack: Permissions request - + +
diff --git a/public/static/Lookup_512.png b/public/static/Lookup_512.png new file mode 100644 index 0000000..c106184 Binary files /dev/null and b/public/static/Lookup_512.png differ diff --git a/src/background/services/permissions_giver/permissions_giver.ts b/src/background/services/permissions_giver/permissions_giver.ts index 417aa9d..c2239d1 100644 --- a/src/background/services/permissions_giver/permissions_giver.ts +++ b/src/background/services/permissions_giver/permissions_giver.ts @@ -1,13 +1,74 @@ -chrome.runtime.onInstalled.addListener((_details) => { - console.log("Handle 'chrome.runtime.onInstalled'"); - (async () => { - await chrome.tabs.create({ +import { + Message, + MessageResponse, + MessageResponseType, + MessageType, +} from "@/shared/messaging"; +import { storage } from "@/shared/storage"; + +class PermissionsGiver { + static async openPermissionsTab() { + console.log("PermissionsGiver.openPermissionsTab()"); + const tab = await chrome.tabs.create({ active: true, url: chrome.runtime.getURL("permissions.html"), }); + await storage.permissions.tabId.set(tab.id || 0); + } + + static async closePermissionsTab() { + console.log("PermissionsGiver.closePermissionsTab()"); + await chrome.tabs.remove(await storage.permissions.tabId.get()); + await storage.permissions.tabId.set(0); + } +} + +chrome.runtime.onInstalled.addListener((_details) => { + console.log("Handle 'chrome.runtime.onInstalled'"); + (async () => { + await PermissionsGiver.openPermissionsTab(); })().catch((err) => { console.error( `Error in 'chrome.runtime.onInstalled' handler: ${(err as Error).toString()}`, ); }); }); + +chrome.runtime.onMessage.addListener( + ( + message: Message, + _sender, + senderResponse: (response: MessageResponse) => void, + ) => { + (async () => { + const { type, target, options: _options } = message; + if (target !== "background") { + return; + } + switch (type) { + case MessageType.PermissionsTabOpen: + await PermissionsGiver.openPermissionsTab(); + break; + case MessageType.PermissionsTabClose: + await PermissionsGiver.closePermissionsTab(); + break; + } + })() + .then(() => { + senderResponse({ + type: MessageResponseType.ResultOk, + } satisfies MessageResponse); + }) + .catch((err) => { + console.error( + `Error in 'chrome.runtime.onMessage' handler: ${(err as Error).toString()}`, + ); + senderResponse({ + type: MessageResponseType.ResultError, + reason: (err as Error).toString(), + } satisfies MessageResponse); + }); + // NOTE: We need to return `true`, because we using `sendResponse` asynchronously + return true; + }, +); diff --git a/src/shared/messaging.ts b/src/shared/messaging.ts index 79716c6..260240a 100644 --- a/src/shared/messaging.ts +++ b/src/shared/messaging.ts @@ -8,6 +8,8 @@ export enum MessageType { RecordingResume = "RecordingResume", RecordingCancel = "RecordingCancel", RecordingSave = "RecordingSave", + PermissionsTabOpen = "PermissionsPageOpen", + PermissionsTabClose = "PermissionsPageClose", // offscreen RecorderCreate = "RecorderCreate", RecorderStart = "RecorderStart", @@ -96,6 +98,18 @@ export const sender = { options, }); }, + permissionsTabOpen: () => { + return chrome.runtime.sendMessage({ + type: MessageType.PermissionsTabOpen, + target: "background", + }); + }, + permissionsTabClose: () => { + return chrome.runtime.sendMessage({ + type: MessageType.PermissionsTabClose, + target: "background", + }); + }, }, offscreen: { recorderCreate: (options: RecorderCreateOptions) => { diff --git a/src/shared/storage.ts b/src/shared/storage.ts index b0ab1f7..dbfc0e3 100644 --- a/src/shared/storage.ts +++ b/src/shared/storage.ts @@ -8,6 +8,7 @@ export enum StorageKey { DevicesVideoEnabled = "devices.video.enabled", DevicesVideoId = "devices.video.id", DevicesVideoName = "devices.video.name", + PermissionsTabId = "permissions.tabId", RecordingState = "recording.state", RecordingDownloadId = "recording.download_id", UiCameraBubbleEnabled = "ui.cameraBubble.enabled", @@ -34,6 +35,7 @@ type StorageValueTypeMap = { [StorageKey.DevicesVideoEnabled]: boolean; [StorageKey.DevicesVideoId]: string; [StorageKey.DevicesVideoName]: string; + [StorageKey.PermissionsTabId]: number; [StorageKey.RecordingState]: RecordingState; [StorageKey.RecordingDownloadId]: number; [StorageKey.UiCameraBubbleEnabled]: boolean; @@ -85,6 +87,9 @@ export const storage = { name: createStorageSetterGetter(StorageKey.DevicesVideoName), }, }, + permissions: { + tabId: createStorageSetterGetter(StorageKey.PermissionsTabId), + }, recording: { state: createStorageSetterGetter(StorageKey.RecordingState), downloadId: createStorageSetterGetter(StorageKey.RecordingDownloadId), diff --git a/src/ui/pages/permissions/Permissions.tsx b/src/ui/pages/permissions/Permissions.tsx new file mode 100644 index 0000000..899f685 --- /dev/null +++ b/src/ui/pages/permissions/Permissions.tsx @@ -0,0 +1,164 @@ +import { useEffect, useState } from "react"; +import { + IconVideo, + IconMicrophone, + IconLoader2, + IconCircleCheck, + IconCircleX, +} from "@tabler/icons-react"; +import lookup512 from "/static/Lookup_512.png"; +import { sender } from "@/shared/messaging"; + +export const Permissions = () => { + const [videoPermissionState, setVideoPermissionState] = + useState("prompt"); + const [micPermissionsState, setMicPermissionsState] = + useState("prompt"); + + useEffect(() => { + (async () => { + const permissionsStatus = await navigator.permissions.query({ + name: "camera", + }); + setVideoPermissionState(permissionsStatus.state); + permissionsStatus.onchange = () => { + setVideoPermissionState(permissionsStatus.state); + }; + })().catch((err) => { + console.error( + `Can't get permissions status for video: ${(err as Error).toString()}`, + ); + }); + }, []); + + useEffect(() => { + (async () => { + const permissionsStatus = await navigator.permissions.query({ + name: "microphone", + }); + setMicPermissionsState(permissionsStatus.state); + permissionsStatus.onchange = () => { + setMicPermissionsState(permissionsStatus.state); + }; + })().catch((err) => { + console.error( + `Can't get permissions status for video: ${(err as Error).toString()}`, + ); + }); + }, []); + + useEffect(() => { + if ( + videoPermissionState !== "granted" || + micPermissionsState !== "granted" + ) { + return; + } + setTimeout(() => { + sender.background.permissionsTabClose().catch((err) => { + console.error( + `Can't send event to background: ${(err as Error).toString()}`, + ); + }); + }, 3 * 1000); + }, [micPermissionsState, videoPermissionState]); + + useEffect(() => { + (async () => { + await navigator.mediaDevices.getUserMedia({ + video: true, + }); + })().catch((err) => { + console.error( + `Error on 'getUserMedia' for video device permissions: ${(err as Error).toString()}`, + ); + }); + }, []); + + useEffect(() => { + (async () => { + await navigator.mediaDevices.getUserMedia({ + audio: true, + }); + })().catch((err) => { + console.error( + `Can't on 'getUserMedia' for audio device permissions: ${(err as Error).toString()}`, + ); + }); + }, []); + + return ( +
+ +
+ {videoPermissionState === "denied" || + micPermissionsState === "denied" ? ( +

+ You denied access to required devices. Please enable them in your + browser settings and close this tab. +

+ ) : ( +

+ Now the browser will ask for your permission to use your camera and + microphone. Please do not close this tab, it will close + automatically. +

+ )} +
+
+
+ + {videoPermissionState === "prompt" ? ( + + ) : videoPermissionState === "granted" ? ( + + ) : ( + + )} +
+
+ + {micPermissionsState === "prompt" ? ( + + ) : micPermissionsState === "granted" ? ( + + ) : ( + + )} +
+
+
+ ); +}; diff --git a/src/ui/pages/permissions/permissions.ts b/src/ui/pages/permissions/permissions.ts index 2edd7ab..8e3b9be 100644 --- a/src/ui/pages/permissions/permissions.ts +++ b/src/ui/pages/permissions/permissions.ts @@ -1,3 +1,11 @@ -(async () => { - await navigator.mediaDevices.getUserMedia({ audio: true, video: true }); -})().catch((err) => console.error((err as Error).toString())); +import React from "react"; +import ReactDOM from "react-dom/client"; +import { Permissions } from "./Permissions"; + +const root = document.getElementById("root"); + +if (!root) { + console.error("Can't find element with id 'root"); +} else { + ReactDOM.createRoot(root).render(React.createElement(Permissions)); +} diff --git a/src/ui/pages/popup/Popup.tsx b/src/ui/pages/popup/Popup.tsx index c4b178b..03d5408 100644 --- a/src/ui/pages/popup/Popup.tsx +++ b/src/ui/pages/popup/Popup.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { RecordingState, StorageKey } from "@/shared/storage"; import { sender } from "@/shared/messaging"; import useStorageValue from "@/ui/hooks/useStorageValue"; @@ -119,13 +120,13 @@ const RecordingControl = () => { className="h-[28px] w-[28px] stroke-black transition-colors hover:stroke-[#00d492]" stroke={2} onClick={() => { - (async () => { - await sender.background.recordingComplete(); - })().catch((err) => - console.error( - `Can't complete and download recording: ${(err as Error).toString()}`, - ), - ); + sender.background + .recordingComplete() + .catch((err) => + console.error( + `Can't complete and download recording: ${(err as Error).toString()}`, + ), + ); }} /> { }; export const Popup = () => { + const [micPermissionsState, setMicPermissionsState] = + useState("prompt"); + const [videoPermissionsState, setVideoPermissionsState] = + useState("prompt"); + + useEffect(() => { + (async () => { + const permissionsStatus = await navigator.permissions.query({ + name: "camera", + }); + setVideoPermissionsState(permissionsStatus.state); + permissionsStatus.onchange = () => { + setVideoPermissionsState(permissionsStatus.state); + }; + })().catch((err) => { + console.error( + `Can't get permissions status for video: ${(err as Error).toString()}`, + ); + }); + }, []); + + useEffect(() => { + (async () => { + const permissionsStatus = await navigator.permissions.query({ + name: "microphone", + }); + setMicPermissionsState(permissionsStatus.state); + permissionsStatus.onchange = () => { + setMicPermissionsState(permissionsStatus.state); + }; + })().catch((err) => { + console.error( + `Can't get permissions status for video: ${(err as Error).toString()}`, + ); + }); + }, []); + return (
{
- - - + {micPermissionsState === "granted" && + videoPermissionsState === "granted" ? ( + <> + + + + + ) : ( +
+
+

+ Extension requires permissions to access your camera and + microphone +

+
+ +
+ )}
); diff --git a/src/ui/styles/global.css b/src/ui/styles/global.css index e426ada..6233bc0 100644 --- a/src/ui/styles/global.css +++ b/src/ui/styles/global.css @@ -6,8 +6,8 @@ --color-klack-charcoal-700: oklch(0.3 0 0); --color-klack-charcoal-800: oklch(0.25 0 0); --color-klack-charcoal-900: oklch(0.2 0 0); + --color-klack-emerald-400: oklch(0.7688 0.1687 161.95); --color-klack-red-500: oklch(0.7 0.192 23.51); --color-klack-red-600: oklch(0.65 0.1825 23.69); - --color-klack-red-700: oklch(0.59 0.1619 23.45); --font-dosis: "Dosis", sans-serif; }