58 |
59 |
60 |
61 | Camano, Washington On the beach Jun 1 – 6 $910 per night
62 |
63 |
64 |
72 |
73 |
74 | `;
75 |
76 | // test('templatize', () => {
77 | // const out = templatize(sample);
78 | // console.log('in length', sample.length);
79 | // console.log('out length', out.length);
80 | // console.log(out);
81 | // expect(templatize(sample)).toBe(``);
82 | // });
83 |
84 | test("templatize", () => {
85 | // const dom = new DOMParser().parseFromString(sample, 'text/html');
86 |
87 | const out = templatize(sample);
88 | // const out = templatize(dom.documentElement);
89 | console.log(out);
90 | });
91 |
--------------------------------------------------------------------------------
/src/helpers/simplifyDom.ts:
--------------------------------------------------------------------------------
1 | import { callRPC } from "./rpc/pageRPC";
2 | import { truthyFilter } from "./utils";
3 |
4 | export async function getSimplifiedDom() {
5 | const fullDom = await callRPC("getAnnotatedDOM", [], 3);
6 | if (!fullDom || typeof fullDom !== "string") return null;
7 |
8 | const dom = new DOMParser().parseFromString(fullDom, "text/html");
9 |
10 | // Mount the DOM to the document in an iframe so we can use getComputedStyle
11 |
12 | const interactiveElements: HTMLElement[] = [];
13 |
14 | const simplifiedDom = generateSimplifiedDom(
15 | dom.documentElement,
16 | interactiveElements,
17 | ) as HTMLElement;
18 |
19 | return simplifiedDom;
20 | }
21 |
22 | export function generateSimplifiedDom(
23 | element: ChildNode,
24 | interactiveElements: HTMLElement[],
25 | ): ChildNode | null {
26 | if (element.nodeType === Node.TEXT_NODE && element.textContent?.trim()) {
27 | return document.createTextNode(element.textContent + " ");
28 | }
29 |
30 | if (!(element instanceof HTMLElement || element instanceof SVGElement))
31 | return null;
32 |
33 | const isVisible = element.getAttribute("data-visible") === "true";
34 | if (!isVisible) return null;
35 |
36 | let children = Array.from(element.childNodes)
37 | .map((c) => generateSimplifiedDom(c, interactiveElements))
38 | .filter(truthyFilter);
39 |
40 | // Don't bother with text that is the direct child of the body
41 | if (element.tagName === "BODY")
42 | children = children.filter((c) => c.nodeType !== Node.TEXT_NODE);
43 |
44 | const interactive =
45 | element.getAttribute("data-interactive") === "true" ||
46 | element.hasAttribute("role");
47 | const hasLabel =
48 | element.hasAttribute("aria-label") || element.hasAttribute("name");
49 | const includeNode = interactive || hasLabel;
50 |
51 | if (!includeNode && children.length === 0) return null;
52 | if (!includeNode && children.length === 1) {
53 | return children[0];
54 | }
55 |
56 | const container = document.createElement(element.tagName);
57 |
58 | const allowedAttributes = [
59 | "aria-label",
60 | "data-name",
61 | "name",
62 | "type",
63 | "placeholder",
64 | "value",
65 | "role",
66 | "title",
67 | ];
68 |
69 | for (const attr of allowedAttributes) {
70 | if (element.hasAttribute(attr)) {
71 | container.setAttribute(attr, element.getAttribute(attr) as string);
72 | }
73 | }
74 | if (interactive) {
75 | interactiveElements.push(element as HTMLElement);
76 | container.setAttribute("id", element.getAttribute("data-id") as string);
77 | }
78 |
79 | children.forEach((child) => container.appendChild(child));
80 |
81 | return container;
82 | }
83 |
--------------------------------------------------------------------------------
/src/helpers/utils.ts:
--------------------------------------------------------------------------------
1 | export async function sleep(ms: number) {
2 | return new Promise((resolve) => setTimeout(resolve, ms));
3 | }
4 |
5 | export function truthyFilter
(value: T | null | undefined): value is T {
6 | return Boolean(value);
7 | }
8 |
9 | export async function waitFor(
10 | predicate: () => Promise,
11 | interval: number,
12 | _maxChecks: number,
13 | rejectOnTimeout = true,
14 | ): Promise {
15 | // special case for 0 maxChecks (wait forever)
16 | const maxChecks = _maxChecks === 0 ? Infinity : _maxChecks;
17 | let checkCount = 0;
18 | return new Promise((resolve, reject) => {
19 | const intervalId = setInterval(async () => {
20 | if (await predicate()) {
21 | clearInterval(intervalId);
22 | resolve();
23 | } else {
24 | checkCount++;
25 | if (checkCount >= maxChecks) {
26 | clearInterval(intervalId);
27 | if (rejectOnTimeout) {
28 | reject(new Error("Timed out waiting for condition"));
29 | } else {
30 | resolve();
31 | }
32 | }
33 | }
34 | }, interval);
35 | });
36 | }
37 |
38 | export async function waitTillStable(
39 | getSize: () => Promise,
40 | interval: number,
41 | timeout: number,
42 | rejectOnTimeout = false, // default to assuming stable after timeout
43 | ): Promise {
44 | let lastSize = 0;
45 | let countStableSizeIterations = 0;
46 | const minStableSizeIterations = 3;
47 |
48 | return waitFor(
49 | async () => {
50 | const currentSize = await getSize();
51 |
52 | console.log("last: ", lastSize, " <> curr: ", currentSize);
53 |
54 | if (lastSize != 0 && currentSize === lastSize) {
55 | countStableSizeIterations++;
56 | } else {
57 | countStableSizeIterations = 0; //reset the counter
58 | }
59 |
60 | if (countStableSizeIterations >= minStableSizeIterations) {
61 | console.log("Size stable! Assume fully rendered..");
62 | return true;
63 | }
64 |
65 | lastSize = currentSize;
66 | return false;
67 | },
68 | interval,
69 | timeout / interval,
70 | rejectOnTimeout,
71 | );
72 | }
73 |
74 | export function enumKeys(
75 | obj: O,
76 | ): K[] {
77 | return Object.keys(obj) as K[];
78 | }
79 |
80 | export function enumValues(obj: O): O[keyof O][] {
81 | return enumKeys(obj).map((key) => obj[key]);
82 | }
83 |
--------------------------------------------------------------------------------
/src/helpers/vision-agent/determineNavigateAction.ts:
--------------------------------------------------------------------------------
1 | import { parseResponse } from "./parseResponse";
2 | import { QueryResult } from "./determineNextAction";
3 | import { useAppState } from "../../state/store";
4 | import errorChecker from "../errorChecker";
5 | import { fetchResponseFromModel } from "../aiSdkUtils";
6 |
7 | import { schemaToDescription, navigateSchema } from "./tools";
8 |
9 | const navigateSchemaDescription = schemaToDescription(navigateSchema);
10 |
11 | const systemMessage = (voiceMode: boolean) => `
12 | You are a browser automation assistant.
13 |
14 | You can use the following tool:
15 |
16 | ${navigateSchemaDescription}
17 |
18 | You will have access to more tools as you progress through the task.
19 |
20 | You will be given a task to perform.
21 | This is an example of expected response from you:
22 |
23 | {
24 | "thought": "To find latest news on AI, I am navigating to Google.",${
25 | voiceMode
26 | ? `,
27 | "speak": "To find the latest news on AI, I am navigating to Google."`
28 | : ""
29 | }
30 | "action": {
31 | "name": "navigate",
32 | "args": {
33 | "url": "https://www.google.com/"
34 | }
35 | }
36 | }
37 |
38 | Your response must always be in JSON format and must include string "thought"${
39 | voiceMode ? ', string "speak",' : ""
40 | } and object "action", which contains the string "name" of tool of choice, and necessary arguments ("args") if required by the tool.
41 | `;
42 |
43 | export async function determineNavigateAction(
44 | taskInstructions: string,
45 | maxAttempts = 3,
46 | notifyError?: (error: string) => void,
47 | ): Promise {
48 | const model = useAppState.getState().settings.selectedModel;
49 | const voiceMode = useAppState.getState().settings.voiceMode;
50 | const prompt = formatPrompt(taskInstructions);
51 |
52 | for (let i = 0; i < maxAttempts; i++) {
53 | try {
54 | const completion = await fetchResponseFromModel(model, {
55 | systemMessage: systemMessage(voiceMode),
56 | prompt,
57 | jsonMode: true,
58 | });
59 |
60 | const rawResponse = completion.rawResponse;
61 | let action = null;
62 | try {
63 | action = parseResponse(rawResponse);
64 | } catch (e) {
65 | console.error(e);
66 | // TODO: try use LLM to fix format when response is not valid
67 | throw new Error(`Incorrectly formatted response: ${e}`);
68 | }
69 |
70 | return {
71 | usage: completion.usage,
72 | prompt,
73 | rawResponse,
74 | action,
75 | };
76 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
77 | } catch (error: any) {
78 | if (error instanceof Error) {
79 | const recoverable = errorChecker(error, notifyError);
80 | if (!recoverable) {
81 | throw error;
82 | }
83 | } else {
84 | console.error("Unexpected determineNextAction error:");
85 | console.error(error);
86 | }
87 | }
88 | }
89 | const errMsg = `Failed to complete query after ${maxAttempts} attempts. Please try again later.`;
90 | if (notifyError) {
91 | notifyError(errMsg);
92 | }
93 | throw new Error(errMsg);
94 | }
95 |
96 | export function formatPrompt(taskInstructions: string) {
97 | return `The user requests the following task:
98 |
99 | ${taskInstructions}
100 |
101 | Current time: ${new Date().toLocaleString()}
102 | `;
103 | }
104 |
--------------------------------------------------------------------------------
/src/helpers/vision-agent/parseResponse.ts:
--------------------------------------------------------------------------------
1 | import { toolSchemaUnion, type ToolOperation } from "./tools";
2 | import { fromError } from "zod-validation-error";
3 |
4 | export type Action = {
5 | thought: string;
6 | speak?: string;
7 | operation: ToolOperation;
8 | };
9 |
10 | // sometimes AI replies with a JSON wrapped in triple backticks
11 | export function extractJsonFromMarkdown(input: string): string[] {
12 | // Create a regular expression to capture code wrapped in triple backticks
13 | const regex = /```(json)?\s*([\s\S]*?)\s*```/g;
14 |
15 | const results = [];
16 | let match;
17 | while ((match = regex.exec(input)) !== null) {
18 | // If 'json' is specified, add the content to the results array
19 | if (match[1] === "json") {
20 | results.push(match[2]);
21 | } else if (match[2].startsWith("{")) {
22 | results.push(match[2]);
23 | }
24 | }
25 | return results;
26 | }
27 |
28 | export function parseResponse(rawResponse: string): Action {
29 | let response;
30 | try {
31 | response = JSON.parse(rawResponse);
32 | } catch (_e) {
33 | try {
34 | response = JSON.parse(extractJsonFromMarkdown(rawResponse)[0]);
35 | } catch (_e) {
36 | throw new Error("Response does not contain valid JSON.");
37 | }
38 | }
39 | if (response.thought == null || response.action == null) {
40 | throw new Error("Invalid response: Thought and Action are required");
41 | }
42 | let operation;
43 | try {
44 | operation = toolSchemaUnion.parse(response.action);
45 | } catch (err) {
46 | const validationError = fromError(err);
47 | // user friendly error message
48 | throw new Error(validationError.toString());
49 | }
50 | if ("speak" in response) {
51 | return {
52 | thought: response.thought,
53 | speak: response.speak,
54 | operation,
55 | };
56 | } else {
57 | return {
58 | thought: response.thought,
59 | operation,
60 | };
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/helpers/vision-agent/tools.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const clickSchema = z.object({
4 | name: z.literal("click"),
5 | description: z
6 | .literal("Click on an element with the uid on the annotation.")
7 | .optional(),
8 | args: z.object({
9 | uid: z.string(),
10 | }),
11 | });
12 |
13 | export const setValueSchema = z.object({
14 | name: z.literal("setValue"),
15 | description: z
16 | .literal(
17 | "Focus on and set the value of an input element with the uid on the annotation.",
18 | )
19 | .optional(),
20 | args: z.object({
21 | uid: z.string(),
22 | value: z.string(),
23 | }),
24 | });
25 |
26 | export const setValueAndEnterSchema = z.object({
27 | name: z.literal("setValueAndEnter"),
28 | description: z
29 | .literal(
30 | 'Like "setValue", except then it presses ENTER. Use this tool can submit the form when there\'s no "submit" button.',
31 | )
32 | .optional(),
33 | args: z.object({
34 | uid: z.string(),
35 | value: z.string(),
36 | }),
37 | });
38 |
39 | export const navigateSchema = z.object({
40 | name: z.literal("navigate"),
41 | description: z
42 | .literal(
43 | "Navigate to a new page. The value should be a URL. Use this tool only when the current task requires navigating to a new page.",
44 | )
45 | .optional(),
46 | args: z.object({
47 | url: z.string(),
48 | }),
49 | });
50 |
51 | export const scrollSchema = z.object({
52 | name: z.literal("scroll"),
53 | description: z
54 | .literal(
55 | 'Scroll the page to see the other parts. Use "up" or "down" to scroll 2/3 of height of the window. Use "top" or "bottom" to quickly scroll to the top or bottom of the page.',
56 | )
57 | .optional(),
58 | args: z.object({
59 | value: z.string(),
60 | }),
61 | });
62 |
63 | export const waitSchema = z.object({
64 | name: z.literal("wait"),
65 | description: z
66 | .literal(
67 | "Wait for 3 seconds before the next action. Useful when the page is loading.",
68 | )
69 | .optional(),
70 | args: z.object({}).optional(),
71 | });
72 |
73 | export const finishSchema = z.object({
74 | name: z.literal("finish"),
75 | description: z.literal("Indicate the task is finished").optional(),
76 | args: z.object({}).optional(),
77 | });
78 |
79 | export const failSchema = z.object({
80 | name: z.literal("fail"),
81 | description: z
82 | .literal("Indicate that you are unable to complete the task")
83 | .optional(),
84 | args: z.object({}).optional(),
85 | });
86 |
87 | export const toolSchemaUnion = z.discriminatedUnion("name", [
88 | clickSchema,
89 | setValueSchema,
90 | setValueAndEnterSchema,
91 | navigateSchema,
92 | scrollSchema,
93 | waitSchema,
94 | finishSchema,
95 | failSchema,
96 | ]);
97 | const allTools = toolSchemaUnion.options;
98 | type ToolSchema = (typeof allTools)[number];
99 |
100 | export type ToolOperation = z.infer;
101 |
102 | export function schemaToDescription(schema: ToolSchema): string {
103 | let description = "";
104 | const shape = schema.shape;
105 | const name = shape.name._def.value;
106 | const descriptionText = shape.description.unwrap()._def.value;
107 | description += `Name: ${name}\nDescription: ${descriptionText}\n`;
108 |
109 | const args = shape.args;
110 | // If the tool has arguments, list them. If entire args is ZodOptional, there are no arguments.
111 | if (args instanceof z.ZodObject && Object.keys(args.shape).length > 0) {
112 | description += "Arguments:\n";
113 | Object.entries(args.shape).forEach(([key, value]) => {
114 | const argType = value instanceof z.ZodString ? "string" : "unknown";
115 | description += ` - ${key} (${argType})\n`;
116 | });
117 | } else {
118 | description += "No arguments.\n";
119 | }
120 |
121 | return description;
122 | }
123 |
124 | function getAllToolsDescriptions(): string {
125 | return allTools.map(schemaToDescription).join("\n");
126 | }
127 | export const allToolsDescriptions = getAllToolsDescriptions();
128 |
--------------------------------------------------------------------------------
/src/helpers/voiceControl.ts:
--------------------------------------------------------------------------------
1 | import { useAppState } from "../state/store";
2 | import OpenAI from "openai";
3 |
4 | type SetTranscriptionFunction = (transcript: string, isFinal: boolean) => void;
5 |
6 | class VoiceControlManager {
7 | private recognition: SpeechRecognition | null;
8 | private cumulativeTranscript = "";
9 | private setTranscription: SetTranscriptionFunction | null = null;
10 |
11 | constructor() {
12 | const SpeechRecognition =
13 | window.SpeechRecognition || window.webkitSpeechRecognition;
14 | if (SpeechRecognition) {
15 | this.recognition = new SpeechRecognition();
16 | this.recognition.continuous = true;
17 | this.recognition.interimResults = true;
18 | this.recognition.lang = "en-US";
19 |
20 | this.recognition.onresult = (event) => {
21 | let interimTranscript = "";
22 | for (let i = event.resultIndex; i < event.results.length; ++i) {
23 | if (event.results[i].isFinal) {
24 | const transcript = event.results[i][0].transcript;
25 | this.cumulativeTranscript += transcript.trim() + " ";
26 | } else {
27 | interimTranscript += event.results[i][0].transcript;
28 | }
29 | }
30 | if (this.setTranscription) {
31 | this.setTranscription(
32 | this.cumulativeTranscript + interimTranscript,
33 | false,
34 | );
35 | }
36 | };
37 |
38 | this.recognition.onerror = (event) => {
39 | console.error("Speech recognition error:", event.error);
40 | };
41 | } else {
42 | console.error("Browser does not support Speech Recognition.");
43 | this.recognition = null;
44 | }
45 | }
46 |
47 | public startListening = async (): Promise => {
48 | if (!this.recognition) {
49 | console.error("Speech Recognition is not initialized.");
50 | return;
51 | }
52 |
53 | this.cumulativeTranscript = "";
54 | this.setTranscription = useAppState.getState().ui.actions.setInstructions;
55 | this.recognition.start();
56 | };
57 |
58 | public stopListening = (): void => {
59 | if (this.recognition) {
60 | this.recognition.stop();
61 | }
62 | if (this.setTranscription && this.cumulativeTranscript !== "") {
63 | this.setTranscription(this.cumulativeTranscript, true);
64 | }
65 | this.setTranscription = null;
66 | };
67 |
68 | public basicSpeak = (text: string): void => {
69 | const utterance = new SpeechSynthesisUtterance(text);
70 | utterance.rate = 2;
71 | speechSynthesis.speak(utterance);
72 | };
73 |
74 | public speak = async (text: string, onError: (error: string) => void) => {
75 | const key = useAppState.getState().settings.openAIKey ?? undefined;
76 | const openai = new OpenAI({
77 | apiKey: key,
78 | dangerouslyAllowBrowser: true,
79 | });
80 |
81 | try {
82 | const mp3Response = await openai.audio.speech.create({
83 | model: "tts-1",
84 | voice: "nova",
85 | input: text,
86 | speed: 1,
87 | });
88 | const arrayBuffer = await mp3Response.arrayBuffer();
89 | const blob = new Blob([arrayBuffer], { type: "audio/mp3" });
90 | const audioUrl = URL.createObjectURL(blob);
91 | const audio = new Audio(audioUrl);
92 | audio.play();
93 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
94 | } catch (error: any) {
95 | console.error("Error generating or playing speech:", error);
96 | onError(error.message);
97 | }
98 | };
99 | }
100 |
101 | export const voiceControl = new VoiceControlManager();
102 |
--------------------------------------------------------------------------------
/src/pages/background/index.ts:
--------------------------------------------------------------------------------
1 | import reloadOnUpdate from "virtual:reload-on-update-in-background-script";
2 | import "webextension-polyfill";
3 |
4 | reloadOnUpdate("pages/background");
5 |
6 | /**
7 | * Extension reloading is necessary because the browser automatically caches the css.
8 | * If you do not use the css of the content script, please delete it.
9 | */
10 | reloadOnUpdate("pages/content/style.scss");
11 |
12 | console.log("background loaded");
13 |
14 | // Allows users to open the side panel by clicking on the action toolbar icon
15 | chrome.sidePanel
16 | .setPanelBehavior({ openPanelOnActionClick: true })
17 | .catch((error) => console.error(error));
18 |
19 | chrome.runtime.onMessage.addListener((message) => {
20 | if (message.action === "injectFunctions") {
21 | if (message.tabId == null) {
22 | console.log("no active tab found");
23 | } else {
24 | chrome.scripting.executeScript({
25 | target: { tabId: message.tabId },
26 | files: ["assets/js/mainWorld.js"],
27 | world: "MAIN",
28 | });
29 | }
30 | return true;
31 | }
32 | });
33 |
--------------------------------------------------------------------------------
/src/pages/content/attachFile.ts:
--------------------------------------------------------------------------------
1 | function base64ToBlob(base64: string, mimeType = "") {
2 | const byteCharacters = atob(base64);
3 | const byteNumbers = Array.from(byteCharacters, (char) => char.charCodeAt(0));
4 | const byteArray = new Uint8Array(byteNumbers);
5 | return new Blob([byteArray], { type: mimeType });
6 | }
7 |
8 | export default function attachFile(data: string, selector: string) {
9 | const screenshotBlob = base64ToBlob(data, "image/png");
10 | // Create a virtual input element
11 | const input = document.createElement("input");
12 | input.type = "file";
13 | input.style.display = "none";
14 |
15 | // Append to the document
16 | document.body.appendChild(input);
17 |
18 | // Simulate file input for the screenshot blob
19 | const dataTransfer = new DataTransfer();
20 | dataTransfer.items.add(new File([screenshotBlob], "screenshot.png"));
21 | input.files = dataTransfer.files;
22 |
23 | // Find the actual file input on the page and set its files property
24 | const actualFileInput = document.querySelector(selector) as HTMLInputElement;
25 | console.log(actualFileInput, selector);
26 | if (!actualFileInput) {
27 | console.log("could not find file input");
28 | return;
29 | }
30 | actualFileInput.files = input.files;
31 | console.log(actualFileInput.files);
32 |
33 | actualFileInput.dispatchEvent(
34 | new Event("input", { bubbles: true, composed: true }),
35 | );
36 | actualFileInput.dispatchEvent(new Event("change", { bubbles: true }));
37 |
38 | // Clean up
39 | document.body.removeChild(input);
40 | }
41 |
--------------------------------------------------------------------------------
/src/pages/content/copyToClipboard.ts:
--------------------------------------------------------------------------------
1 | // copy provided text to clipboard
2 | export async function copyToClipboard(text: string) {
3 | await navigator.clipboard.writeText(text);
4 | }
5 |
--------------------------------------------------------------------------------
/src/pages/content/domOperations.ts:
--------------------------------------------------------------------------------
1 | // The content script runs inside each page this extension is enabled on
2 | // Do NOT import from here from outside of content script (other than types).
3 |
4 | import getAnnotatedDOM, { getUniqueElementSelectorId } from "./getAnnotatedDOM";
5 | import { copyToClipboard } from "./copyToClipboard";
6 | import attachFile from "./attachFile";
7 | import { drawLabels, removeLabels } from "./drawLabels";
8 | import ripple from "./ripple";
9 | import { getDataFromRenderedMarkdown } from "./reverseMarkdown";
10 | import getViewportPercentage from "./getViewportPercentage";
11 | import { injectMicrophonePermissionIframe } from "./permission";
12 |
13 | function clickWithSelector(selector: string) {
14 | const element = document.querySelector(selector) as HTMLElement;
15 | // get center coordinates of the element
16 | const { x, y } = element.getBoundingClientRect();
17 | const centerX = x + element.offsetWidth / 2;
18 | const centerY = y + element.offsetHeight / 2;
19 | ripple(centerX, centerY);
20 | if (element) {
21 | element.click();
22 | }
23 | }
24 |
25 | export const rpcMethods = {
26 | clickWithSelector,
27 | getAnnotatedDOM,
28 | getUniqueElementSelectorId,
29 | ripple,
30 | copyToClipboard,
31 | attachFile,
32 | drawLabels,
33 | removeLabels,
34 | getDataFromRenderedMarkdown,
35 | getViewportPercentage,
36 | injectMicrophonePermissionIframe,
37 | } as const;
38 |
39 | export type RPCMethods = typeof rpcMethods;
40 | type MethodName = keyof RPCMethods;
41 |
42 | export type RPCMessage = {
43 | [K in MethodName]: {
44 | method: K;
45 | payload: Parameters;
46 | };
47 | }[MethodName];
48 |
49 | // This function should run in the content script
50 | export const initializeRPC = () => {
51 | chrome.runtime.onMessage.addListener(
52 | (message: RPCMessage, sender, sendResponse): true | undefined => {
53 | const { method, payload } = message;
54 | console.log("RPC listener", method);
55 | if (method in rpcMethods) {
56 | // @ts-expect-error - we know this is valid (see pageRPC)
57 | const resp = rpcMethods[method as keyof RPCMethods](...payload);
58 | if (resp instanceof Promise) {
59 | resp.then((resolvedResp) => {
60 | sendResponse(resolvedResp);
61 | });
62 | } else {
63 | sendResponse(resp);
64 | }
65 | return true;
66 | }
67 | },
68 | );
69 | };
70 |
--------------------------------------------------------------------------------
/src/pages/content/getAnnotatedDOM.ts:
--------------------------------------------------------------------------------
1 | import { TAXY_ELEMENT_SELECTOR } from "../../constants";
2 |
3 | function isInteractive(
4 | element: HTMLElement,
5 | style: CSSStyleDeclaration,
6 | ): boolean {
7 | return (
8 | element.tagName === "A" ||
9 | element.tagName === "INPUT" ||
10 | element.tagName === "BUTTON" ||
11 | element.tagName === "SELECT" ||
12 | element.tagName === "TEXTAREA" ||
13 | element.hasAttribute("onclick") ||
14 | element.hasAttribute("onmousedown") ||
15 | element.hasAttribute("onmouseup") ||
16 | element.hasAttribute("onkeydown") ||
17 | element.hasAttribute("onkeyup") ||
18 | style.cursor === "pointer"
19 | );
20 | }
21 |
22 | function isVisible(element: HTMLElement, style: CSSStyleDeclaration): boolean {
23 | return (
24 | style.opacity !== "" &&
25 | style.display !== "none" &&
26 | style.visibility !== "hidden" &&
27 | style.opacity !== "0" &&
28 | element.getAttribute("aria-hidden") !== "true"
29 | );
30 | }
31 |
32 | let currentElements: HTMLElement[] = [];
33 |
34 | function traverseDOM(node: Node, pageElements: HTMLElement[]) {
35 | const clonedNode = node.cloneNode(false) as Node;
36 |
37 | if (node.nodeType === Node.ELEMENT_NODE) {
38 | const element = node as HTMLElement;
39 | const style = window.getComputedStyle(element);
40 |
41 | const clonedElement = clonedNode as HTMLElement;
42 |
43 | pageElements.push(element);
44 | clonedElement.setAttribute("data-id", (pageElements.length - 1).toString());
45 | clonedElement.setAttribute(
46 | "data-interactive",
47 | isInteractive(element, style).toString(),
48 | );
49 | clonedElement.setAttribute(
50 | "data-visible",
51 | isVisible(element, style).toString(),
52 | );
53 | }
54 |
55 | node.childNodes.forEach((child) => {
56 | const result = traverseDOM(child, pageElements);
57 | clonedNode.appendChild(result.clonedDOM);
58 | });
59 |
60 | return {
61 | pageElements,
62 | clonedDOM: clonedNode,
63 | };
64 | }
65 |
66 | /**
67 | * getAnnotatedDom returns the pageElements array and a cloned DOM
68 | * with data-pe-idx attributes added to each element in the copy.
69 | */
70 | export default function getAnnotatedDOM() {
71 | currentElements = [];
72 | const result = traverseDOM(document.documentElement, currentElements);
73 | return (result.clonedDOM as HTMLElement).outerHTML;
74 | }
75 |
76 | // idempotent function to get a unique id for an element
77 | export function getUniqueElementSelectorId(id: number): string {
78 | const element = currentElements[id];
79 | // element may already have a unique id
80 | let uniqueId = element.getAttribute(TAXY_ELEMENT_SELECTOR);
81 | if (uniqueId) return uniqueId;
82 | uniqueId = Math.random().toString(36).substring(2, 10);
83 | element.setAttribute(TAXY_ELEMENT_SELECTOR, uniqueId);
84 | return uniqueId;
85 | }
86 |
--------------------------------------------------------------------------------
/src/pages/content/getViewportPercentage.ts:
--------------------------------------------------------------------------------
1 | export default function getViewportPercentage(): number {
2 | // Total height of the document
3 | const documentHeight: number = Math.max(
4 | document.body.scrollHeight,
5 | document.body.offsetHeight,
6 | document.documentElement.clientHeight,
7 | document.documentElement.scrollHeight,
8 | document.documentElement.offsetHeight,
9 | );
10 |
11 | // Viewport height
12 | const viewportHeight: number = window.innerHeight;
13 | // How much has been scrolled
14 | const scrollY: number = window.scrollY;
15 |
16 | // Calculate the end of the current viewport position as a percentage of the total document height
17 | const percentage: number =
18 | ((scrollY + viewportHeight) / documentHeight) * 100;
19 |
20 | return percentage;
21 | }
22 |
--------------------------------------------------------------------------------
/src/pages/content/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * DO NOT USE import someModule from '...';
3 | *
4 | * @issue-url https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/issues/160
5 | *
6 | * Chrome extensions don't support modules in content scripts.
7 | * If you want to use other modules in content scripts, you need to import them via these files.
8 | *
9 | */
10 | import("@pages/content/injected");
11 |
--------------------------------------------------------------------------------
/src/pages/content/injected.ts:
--------------------------------------------------------------------------------
1 | // The content script runs inside each page this extension is enabled on
2 |
3 | import { initializeRPC } from "./domOperations";
4 |
5 | initializeRPC();
6 |
--------------------------------------------------------------------------------
/src/pages/content/mainWorld/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * DO NOT USE import someModule from '...';
3 | *
4 | * @issue-url https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/issues/160
5 | *
6 | * Chrome extensions don't support modules in content scripts.
7 | * If you want to use other modules in content scripts, you need to import them via these files.
8 | *
9 | */
10 | import("@pages/content/mainWorld/mainWorld");
11 |
--------------------------------------------------------------------------------
/src/pages/content/mainWorld/mainWorld.ts:
--------------------------------------------------------------------------------
1 | // This file will be inject dynamically into the page as a content script running in the context of the page
2 | // see Background/index.ts for how this is done
3 |
4 | import { debugMode } from "@src/constants";
5 | import { generateSimplifiedDom } from "@src/helpers/simplifyDom";
6 | import getAnnotatedDOM from "../getAnnotatedDOM";
7 | import { rpcMethods } from "../domOperations";
8 |
9 | async function getSimplifiedDomFromPage() {
10 | const fullDom = getAnnotatedDOM();
11 | if (!fullDom || typeof fullDom !== "string") return null;
12 |
13 | const dom = new DOMParser().parseFromString(fullDom, "text/html");
14 |
15 | // Mount the DOM to the document in an iframe so we can use getComputedStyle
16 |
17 | const interactiveElements: HTMLElement[] = [];
18 |
19 | const simplifiedDom = generateSimplifiedDom(
20 | dom.documentElement,
21 | interactiveElements,
22 | ) as HTMLElement;
23 |
24 | if (!simplifiedDom) {
25 | return null;
26 | }
27 | return simplifiedDom.outerHTML;
28 | }
29 |
30 | if (debugMode) {
31 | console.log("debug mode enabled");
32 | // @ts-expect-error - this is for debugging only
33 | window.WW_RPC_METHODS = {
34 | getSimplifiedDomFromPage,
35 | ...rpcMethods,
36 | };
37 | }
38 |
--------------------------------------------------------------------------------
/src/pages/content/permission.ts:
--------------------------------------------------------------------------------
1 | export const injectMicrophonePermissionIframe = () => {
2 | const iframe = document.createElement("iframe");
3 | iframe.setAttribute("hidden", "hidden");
4 | iframe.setAttribute("id", "permissionsIFrame");
5 | iframe.setAttribute("allow", "microphone");
6 | iframe.src = chrome.runtime.getURL("/src/pages/permission/index.html");
7 | document.body.appendChild(iframe);
8 | };
9 |
--------------------------------------------------------------------------------
/src/pages/content/reverseMarkdown.ts:
--------------------------------------------------------------------------------
1 | export function getDataFromRenderedMarkdown(selector: string) {
2 | const element = document.querySelector(selector);
3 | if (!element) {
4 | return null;
5 | }
6 | const text = Array.from(element.querySelectorAll("p"))
7 | .map((p) => p.textContent)
8 | .join("\n\n");
9 | const codeBlocks = Array.from(element.querySelectorAll("pre code")).map(
10 | (code) => code.textContent,
11 | );
12 |
13 | return { text, codeBlocks };
14 | }
15 |
--------------------------------------------------------------------------------
/src/pages/content/ripple.ts:
--------------------------------------------------------------------------------
1 | import { sleep } from "../../helpers/utils";
2 |
3 | export default function ripple(x: number, y: number) {
4 | const rippleRadius = 30;
5 | const ripple = document.createElement("div");
6 | ripple.classList.add("web-agent-ripple");
7 | ripple.style.width = ripple.style.height = `${rippleRadius * 2}px`;
8 | // Take scroll position into account
9 | ripple.style.top = `${window.scrollY + y - rippleRadius}px`;
10 | ripple.style.left = `${x - rippleRadius}px`;
11 |
12 | document.body.appendChild(ripple);
13 |
14 | // remove after the animation to finish
15 | // but we don't need to `await` it
16 | sleep(800).then(() => {
17 | ripple.remove();
18 | });
19 | }
20 |
--------------------------------------------------------------------------------
/src/pages/content/style.global.scss:
--------------------------------------------------------------------------------
1 | // IMPORTANT: this file will impact the styles on ALL PAGES
2 | // DO NOT add style names that can easily conflict with other pages
3 | // TODO: import this directly from fuji-web instead of copying
4 | .web-agent-ripple {
5 | position: absolute;
6 | border-radius: 50%;
7 | transform: scale(0);
8 | animation: web-agent-ripple 0.5s ease-out;
9 | background-color: rgba(0, 0, 255, 0.961);
10 | pointer-events: none;
11 | z-index: 999999;
12 | }
13 |
14 | @keyframes web-agent-ripple {
15 | to {
16 | transform: scale(3);
17 | opacity: 0;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/pages/content/style.scss:
--------------------------------------------------------------------------------
1 | // IMPORTANT: this file is supposed to be loaded into the shadow dom
2 |
--------------------------------------------------------------------------------
/src/pages/devtools/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Devtools
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/src/pages/devtools/index.ts:
--------------------------------------------------------------------------------
1 | // try {
2 | // chrome.devtools.panels.create(
3 | // "Dev Tools",
4 | // "icon-34.png",
5 | // "src/pages/panel/index.html",
6 | // );
7 | // } catch (e) {
8 | // console.error(e);
9 | // }
10 |
--------------------------------------------------------------------------------
/src/pages/newtab/Newtab.css:
--------------------------------------------------------------------------------
1 | .App {
2 | text-align: center;
3 | }
4 |
5 | .App-logo {
6 | height: 40vmin;
7 | pointer-events: none;
8 | }
9 |
10 | @media (prefers-reduced-motion: no-preference) {
11 | .App-logo {
12 | animation: App-logo-spin infinite 20s linear;
13 | }
14 | }
15 |
16 | .App-header {
17 | min-height: 100vh;
18 | display: flex;
19 | flex-direction: column;
20 | align-items: center;
21 | justify-content: center;
22 | font-size: calc(10px + 2vmin);
23 | color: white;
24 | }
25 |
26 | .App-link {
27 | color: #61dafb;
28 | }
29 |
30 | @keyframes App-logo-spin {
31 | from {
32 | transform: rotate(0deg);
33 | }
34 | to {
35 | transform: rotate(360deg);
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/pages/newtab/Newtab.scss:
--------------------------------------------------------------------------------
1 | $myColor: red;
2 |
3 | h1,
4 | h2,
5 | h3,
6 | h4,
7 | h5,
8 | h6 {
9 | color: $myColor;
10 | }
11 |
--------------------------------------------------------------------------------
/src/pages/newtab/Newtab.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import logo from "@assets/img/logo.svg";
3 | import "@pages/newtab/Newtab.css";
4 | import "@pages/newtab/Newtab.scss";
5 | import useStorage from "@src/shared/hooks/useStorage";
6 | import exampleThemeStorage from "@src/shared/storages/exampleThemeStorage";
7 | import withSuspense from "@src/shared/hoc/withSuspense";
8 | import withErrorBoundary from "@src/shared/hoc/withErrorBoundary";
9 |
10 | const Newtab = () => {
11 | const theme = useStorage(exampleThemeStorage);
12 |
13 | return (
14 |
52 | );
53 | };
54 |
55 | export default withErrorBoundary(
56 | withSuspense(Newtab, Loading ...
),
57 | Error Occur
,
58 | );
59 |
--------------------------------------------------------------------------------
/src/pages/newtab/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
12 | monospace;
13 | }
14 |
--------------------------------------------------------------------------------
/src/pages/newtab/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | New tab
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/pages/newtab/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { createRoot } from "react-dom/client";
3 | import Newtab from "@pages/newtab/Newtab";
4 | import "@pages/newtab/index.css";
5 | import refreshOnUpdate from "virtual:reload-on-update-in-view";
6 |
7 | refreshOnUpdate("pages/newtab");
8 |
9 | function init() {
10 | const appContainer = document.querySelector("#app-container");
11 | if (!appContainer) {
12 | throw new Error("Can not find #app-container");
13 | }
14 | const root = createRoot(appContainer);
15 |
16 | root.render();
17 | }
18 |
19 | init();
20 |
--------------------------------------------------------------------------------
/src/pages/options/Options.css:
--------------------------------------------------------------------------------
1 | .container {
2 | width: 100%;
3 | height: 50vh;
4 | font-size: 2rem;
5 | display: flex;
6 | align-items: center;
7 | justify-content: center;
8 | }
9 |
--------------------------------------------------------------------------------
/src/pages/options/Options.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import "@pages/options/Options.css";
3 |
4 | const Options: React.FC = () => {
5 | return Options
;
6 | };
7 |
8 | export default Options;
9 |
--------------------------------------------------------------------------------
/src/pages/options/index.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/normal-computing/fuji-web/1aec509e4c437ca7764a5b4a56deaeba18691729/src/pages/options/index.css
--------------------------------------------------------------------------------
/src/pages/options/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Options
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/pages/options/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { createRoot } from "react-dom/client";
3 | import Options from "@pages/options/Options";
4 | import "@pages/options/index.css";
5 | import refreshOnUpdate from "virtual:reload-on-update-in-view";
6 |
7 | refreshOnUpdate("pages/options");
8 |
9 | function init() {
10 | const appContainer = document.querySelector("#app-container");
11 | if (!appContainer) {
12 | throw new Error("Can not find #app-container");
13 | }
14 | const root = createRoot(appContainer);
15 | root.render();
16 | }
17 |
18 | init();
19 |
--------------------------------------------------------------------------------
/src/pages/panel/Panel.css:
--------------------------------------------------------------------------------
1 | body {
2 | background-color: #242424;
3 | }
4 |
5 | .container {
6 | color: #ffffff;
7 | }
8 |
--------------------------------------------------------------------------------
/src/pages/panel/Panel.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import "@pages/panel/Panel.css";
3 |
4 | const Panel: React.FC = () => {
5 | return (
6 |
7 |
Dev Tools Panel
8 |
9 | );
10 | };
11 |
12 | export default Panel;
13 |
--------------------------------------------------------------------------------
/src/pages/panel/index.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/normal-computing/fuji-web/1aec509e4c437ca7764a5b4a56deaeba18691729/src/pages/panel/index.css
--------------------------------------------------------------------------------
/src/pages/panel/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Devtools Panel
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/pages/panel/index.tsx:
--------------------------------------------------------------------------------
1 | // import React from "react";
2 | // import { createRoot } from "react-dom/client";
3 | // import Panel from "@pages/panel/Panel";
4 | // import "@pages/panel/index.css";
5 | // import refreshOnUpdate from "virtual:reload-on-update-in-view";
6 |
7 | // refreshOnUpdate("pages/panel");
8 |
9 | // function init() {
10 | // const appContainer = document.querySelector("#app-container");
11 | // if (!appContainer) {
12 | // throw new Error("Can not find #app-container");
13 | // }
14 | // const root = createRoot(appContainer);
15 | // root.render();
16 | // }
17 |
18 | // init();
19 |
--------------------------------------------------------------------------------
/src/pages/permission/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Request Permissions
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/src/pages/permission/requestPermission.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Requests user permission for microphone access.
3 | * @returns {Promise} A Promise that resolves when permission is granted or rejects with an error.
4 | */
5 | export async function getUserPermission(): Promise {
6 | return new Promise((resolve, reject) => {
7 | // Using navigator.mediaDevices.getUserMedia to request microphone access
8 | navigator.mediaDevices
9 | .getUserMedia({ audio: true })
10 | .then((stream) => {
11 | // Permission granted, handle the stream if needed
12 | console.log("Microphone access granted");
13 |
14 | // Stop the tracks to prevent the recording indicator from being shown
15 | stream.getTracks().forEach(function (track) {
16 | track.stop();
17 | });
18 |
19 | resolve();
20 | })
21 | .catch((error) => {
22 | console.error("Error requesting microphone permission", error);
23 |
24 | // Handling different error scenarios
25 | if (error.name === "Permission denied") {
26 | // TODO: catch this error and show a user-friendly message
27 | reject(new Error("MICROPHONE_PERMISSION_DENIED"));
28 | } else {
29 | reject(error);
30 | }
31 | });
32 | });
33 | }
34 |
35 | // Call the function to request microphone permission
36 | getUserPermission();
37 |
--------------------------------------------------------------------------------
/src/pages/popup/Popup.css:
--------------------------------------------------------------------------------
1 | .App {
2 | position: absolute;
3 | top: 0;
4 | bottom: 0;
5 | left: 0;
6 | right: 0;
7 | text-align: center;
8 | height: 100%;
9 | padding: 10px;
10 | background-color: #282c34;
11 | }
12 |
13 | .App-logo {
14 | height: 30vmin;
15 | pointer-events: none;
16 | }
17 |
18 | @media (prefers-reduced-motion: no-preference) {
19 | .App-logo {
20 | animation: App-logo-spin infinite 20s linear;
21 | }
22 | }
23 |
24 | .App-header {
25 | height: 100%;
26 | display: flex;
27 | flex-direction: column;
28 | align-items: center;
29 | justify-content: center;
30 | font-size: calc(10px + 2vmin);
31 | color: white;
32 | }
33 |
34 | .App-link {
35 | color: #61dafb;
36 | }
37 |
38 | @keyframes App-logo-spin {
39 | from {
40 | transform: rotate(0deg);
41 | }
42 | to {
43 | transform: rotate(360deg);
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/pages/popup/Popup.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import logo from "@assets/img/logo.svg";
3 | import "@pages/popup/Popup.css";
4 | import useStorage from "@src/shared/hooks/useStorage";
5 | import exampleThemeStorage from "@src/shared/storages/exampleThemeStorage";
6 | import withSuspense from "@src/shared/hoc/withSuspense";
7 | import withErrorBoundary from "@src/shared/hoc/withErrorBoundary";
8 |
9 | const Popup = () => {
10 | const theme = useStorage(exampleThemeStorage);
11 |
12 | return (
13 |
50 | );
51 | };
52 |
53 | export default withErrorBoundary(
54 | withSuspense(Popup, Loading ...
),
55 | Error Occur
,
56 | );
57 |
--------------------------------------------------------------------------------
/src/pages/popup/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | width: 300px;
3 | height: 260px;
4 | margin: 0;
5 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
6 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
7 | sans-serif;
8 | -webkit-font-smoothing: antialiased;
9 | -moz-osx-font-smoothing: grayscale;
10 |
11 | position: relative;
12 | }
13 |
14 | code {
15 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
16 | monospace;
17 | }
18 |
--------------------------------------------------------------------------------
/src/pages/popup/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Popup
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/pages/popup/index.tsx:
--------------------------------------------------------------------------------
1 | // import React from "react";
2 | // import { createRoot } from "react-dom/client";
3 | // import "@pages/popup/index.css";
4 | // import Popup from "@pages/popup/Popup";
5 | // import refreshOnUpdate from "virtual:reload-on-update-in-view";
6 |
7 | // refreshOnUpdate("pages/popup");
8 |
9 | // function init() {
10 | // const appContainer = document.querySelector("#app-container");
11 | // if (!appContainer) {
12 | // throw new Error("Can not find #app-container");
13 | // }
14 | // const root = createRoot(appContainer);
15 | // root.render();
16 | // }
17 |
18 | // init();
19 |
--------------------------------------------------------------------------------
/src/pages/sidepanel/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | min-height: 100dvh;
3 | margin: 0;
4 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
5 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
6 | sans-serif;
7 | -webkit-font-smoothing: antialiased;
8 | -moz-osx-font-smoothing: grayscale;
9 |
10 | position: relative;
11 | background-color: white;
12 | }
13 |
14 | code {
15 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
16 | monospace;
17 | }
18 |
--------------------------------------------------------------------------------
/src/pages/sidepanel/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Side Panel
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/pages/sidepanel/index.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from "react-dom/client";
2 |
3 | import App from "@src/common/App";
4 |
5 | import refreshOnUpdate from "virtual:reload-on-update-in-view";
6 |
7 | refreshOnUpdate("pages/sidepanel");
8 |
9 | function init() {
10 | const appContainer = document.querySelector("#app-container");
11 | if (!appContainer) {
12 | throw new Error("Can not find #app-container");
13 | }
14 | const root = createRoot(appContainer);
15 | root.render();
16 | }
17 |
18 | init();
19 |
--------------------------------------------------------------------------------
/src/shared/hoc/withErrorBoundary.tsx:
--------------------------------------------------------------------------------
1 | import { Component, ComponentType, ReactElement, ErrorInfo } from "react";
2 |
3 | class ErrorBoundary extends Component<
4 | {
5 | children: ReactElement;
6 | fallback: ReactElement;
7 | },
8 | {
9 | hasError: boolean;
10 | }
11 | > {
12 | state = { hasError: false };
13 |
14 | static getDerivedStateFromError() {
15 | return { hasError: true };
16 | }
17 |
18 | componentDidCatch(error: Error, errorInfo: ErrorInfo) {
19 | console.error(error, errorInfo);
20 | }
21 |
22 | render() {
23 | if (this.state.hasError) {
24 | return this.props.fallback;
25 | }
26 |
27 | return this.props.children;
28 | }
29 | }
30 |
31 | export default function withErrorBoundary>(
32 | Component: ComponentType,
33 | ErrorComponent: ReactElement,
34 | ) {
35 | return function WithErrorBoundary(props: T) {
36 | return (
37 |
38 |
39 |
40 | );
41 | };
42 | }
43 |
--------------------------------------------------------------------------------
/src/shared/hoc/withSuspense.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentType, ReactElement, Suspense } from "react";
2 |
3 | export default function withSuspense>(
4 | Component: ComponentType,
5 | SuspenseComponent: ReactElement,
6 | ) {
7 | return function WithSuspense(props: T) {
8 | return (
9 |
10 |
11 |
12 | );
13 | };
14 | }
15 |
--------------------------------------------------------------------------------
/src/shared/hooks/useStorage.tsx:
--------------------------------------------------------------------------------
1 | import { useSyncExternalStore } from "react";
2 | import { BaseStorage } from "@src/shared/storages/base";
3 |
4 | type WrappedPromise = ReturnType;
5 | const storageMap: Map, WrappedPromise> = new Map();
6 |
7 | export default function useStorage<
8 | Storage extends BaseStorage,
9 | Data = Storage extends BaseStorage ? Data : unknown,
10 | >(storage: Storage) {
11 | const map = storageMap as Map;
12 | const _data = useSyncExternalStore(
13 | storage.subscribe,
14 | storage.getSnapshot,
15 | );
16 |
17 | if (!map.has(storage)) {
18 | map.set(storage, wrapPromise(storage.get()));
19 | }
20 | if (_data !== null) {
21 | map.set(storage, { read: () => _data });
22 | }
23 |
24 | return _data ?? (map.get(storage)!.read() as Data);
25 | }
26 |
27 | function wrapPromise(promise: Promise) {
28 | let status = "pending";
29 | let result: R;
30 | const suspender = promise.then(
31 | (r) => {
32 | status = "success";
33 | result = r;
34 | },
35 | (e) => {
36 | status = "error";
37 | result = e;
38 | },
39 | );
40 |
41 | return {
42 | read() {
43 | if (status === "pending") {
44 | throw suspender;
45 | } else if (status === "error") {
46 | throw result;
47 | } else if (status === "success") {
48 | return result;
49 | }
50 | },
51 | };
52 | }
53 |
--------------------------------------------------------------------------------
/src/shared/images/mergeScreenshots.ts:
--------------------------------------------------------------------------------
1 | // TODO: make it configurable?
2 | const DEFAULT_FONT_SIZE = 40;
3 | const DEFAULT_FONT_STYLE = `${DEFAULT_FONT_SIZE}px serif`;
4 |
5 | export type ImageSourceAttrs = {
6 | src: string;
7 | caption?: string;
8 | opacity?: number | undefined;
9 | };
10 |
11 | type ExtendedImageData = ImageSourceAttrs & {
12 | img: HTMLImageElement;
13 | };
14 |
15 | export type MergeImageOptionsInput = {
16 | format?: string;
17 | quality?: number;
18 | maxFileSizeMB?: number;
19 | padding?: number;
20 | };
21 |
22 | type MergeImageOptions = MergeImageOptionsInput & {
23 | format: string;
24 | padding: number;
25 | };
26 |
27 | export type GetCanvasSize = (
28 | images: ExtendedImageData[],
29 | options: MergeImageOptions,
30 | ) => {
31 | width: number;
32 | height: number;
33 | };
34 |
35 | const getHorizontalLayoutCanvasSize: GetCanvasSize = (images, options) => {
36 | let width = 0;
37 | let height = 0;
38 | images.forEach((image) => {
39 | const padding = options.padding;
40 | width += image.img.width + padding * 2;
41 | height = Math.max(height, image.img.height + padding * 2);
42 | });
43 | return {
44 | width,
45 | height: height + DEFAULT_FONT_SIZE,
46 | };
47 | };
48 |
49 | // Function to get WebP data URL and ensure it's less than 5 MB
50 | function getWebPDataURL(
51 | canvas: HTMLCanvasElement,
52 | maxFileSizeMB: number = 5,
53 | maxQuality = 1,
54 | qualityStep = 0.05,
55 | ) {
56 | const maxFileSizeBytes = maxFileSizeMB * 1024 * 1024;
57 | let quality = maxQuality;
58 | let dataURL = canvas.toDataURL("image/webp", quality);
59 |
60 | // Check the size of the data URL
61 | while (dataURL.length * 0.75 > maxFileSizeBytes && quality > 0) {
62 | quality -= qualityStep; // Decrease quality
63 | dataURL = canvas.toDataURL("image/webp", quality);
64 | }
65 |
66 | return dataURL;
67 | }
68 |
69 | // Defaults
70 | const defaultOptions: MergeImageOptions = {
71 | format: "image/webp",
72 | quality: 1,
73 | maxFileSizeMB: 5,
74 | padding: 40,
75 | };
76 |
77 | const mergeImages = async (
78 | sources: ImageSourceAttrs[] = [],
79 | optionsInput: MergeImageOptionsInput = {},
80 | ) => {
81 | const options: MergeImageOptions = {
82 | ...defaultOptions,
83 | ...optionsInput,
84 | };
85 |
86 | // Setup browser/Node.js specific variables
87 | const canvas = window.document.createElement("canvas");
88 |
89 | // Load sources
90 | const images: Promise[] = sources.map(
91 | (source) =>
92 | new Promise((resolve, reject) => {
93 | // Resolve source and img when loaded
94 | const img = new Image();
95 | img.onerror = () => reject(new Error("Couldn't load image"));
96 | const data = {
97 | ...source,
98 | img,
99 | };
100 | img.onload = () => resolve(data);
101 | img.src = source.src;
102 | }),
103 | );
104 |
105 | // Get canvas context
106 | const ctx = canvas.getContext("2d");
107 | if (!ctx) {
108 | throw new Error("Could not get canvas context");
109 | }
110 |
111 | // When sources have loaded
112 | return await Promise.all(images).then((images) => {
113 | // Set canvas dimensions
114 | const canvasSize = getHorizontalLayoutCanvasSize(images, options);
115 | canvas.width = canvasSize.width;
116 | canvas.height = canvasSize.height;
117 | // fill canvas with gray background
118 | ctx.fillStyle = "#f0f0f0";
119 | ctx.fillRect(0, 0, canvas.width, canvas.height);
120 |
121 | // Draw images and captions to canvas (horizontally)
122 | let x = options.padding;
123 | const y = options.padding;
124 | ctx.textAlign = "center";
125 | ctx.font = DEFAULT_FONT_STYLE;
126 | ctx.fillStyle = "black";
127 | ctx.strokeStyle = "black";
128 | images.forEach((image) => {
129 | ctx.globalAlpha = image.opacity ?? 1;
130 | ctx.drawImage(image.img, x, y);
131 | // border around image
132 | ctx.strokeRect(x, y, image.img.width, image.img.height);
133 | if (image.caption != null) {
134 | ctx.fillText(
135 | image.caption,
136 | x + image.img.width / 2,
137 | y + image.img.height + DEFAULT_FONT_SIZE,
138 | );
139 | }
140 | // Increment x to where the next image should be drawn
141 | x += image.img.width + options.padding;
142 | });
143 |
144 | if (options.format === "image/webp") {
145 | return getWebPDataURL(canvas, options.maxFileSizeMB, options.quality);
146 | }
147 |
148 | return canvas.toDataURL(options.format, options.quality);
149 | });
150 | };
151 |
152 | export default mergeImages;
153 |
--------------------------------------------------------------------------------
/src/shared/storages/base.ts:
--------------------------------------------------------------------------------
1 | export enum StorageType {
2 | Local = "local",
3 | Sync = "sync",
4 | Managed = "managed",
5 | Session = "session",
6 | }
7 |
8 | type ValueOrUpdate = D | ((prev: D) => Promise | D);
9 |
10 | export type BaseStorage = {
11 | get: () => Promise;
12 | set: (value: ValueOrUpdate) => Promise;
13 | getSnapshot: () => D | null;
14 | subscribe: (listener: () => void) => () => void;
15 | };
16 |
17 | export function createStorage(
18 | key: string,
19 | fallback: D,
20 | config?: { storageType?: StorageType },
21 | ): BaseStorage {
22 | let cache: D | null = null;
23 | let listeners: Array<() => void> = [];
24 | const storageType = config?.storageType ?? StorageType.Local;
25 |
26 | const _getDataFromStorage = async (): Promise => {
27 | if (chrome.storage[storageType] === undefined) {
28 | throw new Error(
29 | `Check your storage permission into manifest.json: ${storageType} is not defined`,
30 | );
31 | }
32 | const value = await chrome.storage[storageType].get([key]);
33 | return value[key] ?? fallback;
34 | };
35 |
36 | const _emitChange = () => {
37 | listeners.forEach((listener) => listener());
38 | };
39 |
40 | const set = async (valueOrUpdate: ValueOrUpdate) => {
41 | if (typeof valueOrUpdate === "function") {
42 | // eslint-disable-next-line no-prototype-builtins
43 | if (valueOrUpdate.hasOwnProperty("then")) {
44 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
45 | // @ts-ignore
46 | cache = await valueOrUpdate(cache);
47 | } else {
48 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
49 | // @ts-ignore
50 | cache = valueOrUpdate(cache);
51 | }
52 | } else {
53 | cache = valueOrUpdate;
54 | }
55 | await chrome.storage[storageType].set({ [key]: cache });
56 | _emitChange();
57 | };
58 |
59 | const subscribe = (listener: () => void) => {
60 | listeners = [...listeners, listener];
61 | return () => {
62 | listeners = listeners.filter((l) => l !== listener);
63 | };
64 | };
65 |
66 | const getSnapshot = () => {
67 | return cache;
68 | };
69 |
70 | _getDataFromStorage().then((data) => {
71 | cache = data;
72 | _emitChange();
73 | });
74 |
75 | return {
76 | get: _getDataFromStorage,
77 | set,
78 | getSnapshot,
79 | subscribe,
80 | };
81 | }
82 |
--------------------------------------------------------------------------------
/src/shared/storages/exampleThemeStorage.ts:
--------------------------------------------------------------------------------
1 | import {
2 | BaseStorage,
3 | createStorage,
4 | StorageType,
5 | } from "@src/shared/storages/base";
6 |
7 | type Theme = "light" | "dark";
8 |
9 | type ThemeStorage = BaseStorage & {
10 | toggle: () => void;
11 | };
12 |
13 | const storage = createStorage("theme-storage-key", "light", {
14 | storageType: StorageType.Local,
15 | });
16 |
17 | const exampleThemeStorage: ThemeStorage = {
18 | ...storage,
19 | // TODO: extends your own methods
20 | toggle: () => {
21 | storage.set((currentTheme) => {
22 | return currentTheme === "light" ? "dark" : "light";
23 | });
24 | },
25 | };
26 |
27 | export default exampleThemeStorage;
28 |
--------------------------------------------------------------------------------
/src/state/settings.ts:
--------------------------------------------------------------------------------
1 | import { type Data } from "../helpers/knowledge/index";
2 | import { MyStateCreator } from "./store";
3 | import {
4 | SupportedModels,
5 | findBestMatchingModel,
6 | AgentMode,
7 | } from "../helpers/aiSdkUtils";
8 |
9 | export type SettingsSlice = {
10 | openAIKey: string | undefined;
11 | anthropicKey: string | undefined;
12 | openAIBaseUrl: string | undefined;
13 | anthropicBaseUrl: string | undefined;
14 | geminiKey: string | undefined;
15 | selectedModel: SupportedModels;
16 | agentMode: AgentMode;
17 | voiceMode: boolean;
18 | customKnowledgeBase: Data;
19 | actions: {
20 | update: (values: Partial) => void;
21 | };
22 | };
23 | export const createSettingsSlice: MyStateCreator = (set) => ({
24 | openAIKey: undefined,
25 | anthropicKey: undefined,
26 | openAIBaseUrl: undefined,
27 | anthropicBaseUrl: undefined,
28 | geminiKey: undefined,
29 | agentMode: AgentMode.VisionEnhanced,
30 | selectedModel: SupportedModels.Gpt4Turbo,
31 | voiceMode: false,
32 | customKnowledgeBase: {},
33 | actions: {
34 | update: (values) => {
35 | set((state) => {
36 | const newSettings: SettingsSlice = { ...state.settings, ...values };
37 | newSettings.selectedModel = findBestMatchingModel(
38 | newSettings.selectedModel,
39 | newSettings.agentMode,
40 | newSettings.openAIKey,
41 | newSettings.anthropicKey,
42 | newSettings.geminiKey,
43 | );
44 | // voice model current relies on OpenAI API key
45 | if (!newSettings.openAIKey) {
46 | newSettings.voiceMode = false;
47 | }
48 | state.settings = newSettings;
49 | });
50 | },
51 | },
52 | });
53 |
--------------------------------------------------------------------------------
/src/state/store.ts:
--------------------------------------------------------------------------------
1 | import { merge } from "lodash";
2 | import { create, StateCreator } from "zustand";
3 | import { immer } from "zustand/middleware/immer";
4 | import { createJSONStorage, devtools, persist } from "zustand/middleware";
5 | import { createCurrentTaskSlice, CurrentTaskSlice } from "./currentTask";
6 | import { createUiSlice, UiSlice } from "./ui";
7 | import { createSettingsSlice, SettingsSlice } from "./settings";
8 | import { findBestMatchingModel } from "../helpers/aiSdkUtils";
9 |
10 | export type StoreType = {
11 | currentTask: CurrentTaskSlice;
12 | ui: UiSlice;
13 | settings: SettingsSlice;
14 | };
15 |
16 | export type MyStateCreator = StateCreator<
17 | StoreType,
18 | [["zustand/immer", never]],
19 | [],
20 | T
21 | >;
22 |
23 | export const useAppState = create()(
24 | persist(
25 | immer(
26 | devtools((...a) => ({
27 | currentTask: createCurrentTaskSlice(...a),
28 | ui: createUiSlice(...a),
29 | settings: createSettingsSlice(...a),
30 | })),
31 | ),
32 | {
33 | name: "app-state",
34 | storage: createJSONStorage(() => localStorage),
35 | partialize: (state) => ({
36 | // Stuff we want to persist
37 | ui: {
38 | instructions: state.ui.instructions,
39 | },
40 | settings: {
41 | openAIKey: state.settings.openAIKey,
42 | anthropicKey: state.settings.anthropicKey,
43 | geminiKey: state.settings.geminiKey,
44 | openAIBaseUrl: state.settings.openAIBaseUrl,
45 | anthropicBaseUrl: state.settings.anthropicBaseUrl,
46 | agentMode: state.settings.agentMode,
47 | selectedModel: state.settings.selectedModel,
48 | voiceMode: state.settings.voiceMode,
49 | customKnowledgeBase: state.settings.customKnowledgeBase,
50 | },
51 | }),
52 | merge: (persistedState, currentState) => {
53 | const result = merge(currentState, persistedState);
54 | result.settings.selectedModel = findBestMatchingModel(
55 | result.settings.selectedModel,
56 | result.settings.agentMode,
57 | result.settings.openAIKey,
58 | result.settings.anthropicKey,
59 | result.settings.geminiKey,
60 | );
61 | return result;
62 | },
63 | },
64 | ),
65 | );
66 |
67 | // @ts-expect-error used for debugging
68 | window.getState = useAppState.getState;
69 |
--------------------------------------------------------------------------------
/src/state/ui.ts:
--------------------------------------------------------------------------------
1 | import { MyStateCreator } from "./store";
2 |
3 | export type UiSlice = {
4 | instructions: string | null;
5 | actions: {
6 | setInstructions: (instructions: string) => void;
7 | };
8 | };
9 | export const createUiSlice: MyStateCreator = (set) => ({
10 | instructions: null,
11 | actions: {
12 | setInstructions: (instructions) => {
13 | set((state) => {
14 | state.ui.instructions = instructions;
15 | });
16 | },
17 | },
18 | });
19 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | };
9 |
--------------------------------------------------------------------------------
/test-utils/jest.setup.js:
--------------------------------------------------------------------------------
1 | // Do what you need to set up your test
2 | console.log("setup test: jest.setup.js");
3 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "strict": true,
4 | "noEmit": true,
5 | "baseUrl": ".",
6 | "allowJs": false,
7 | "target": "esnext",
8 | "module": "esnext",
9 | "jsx": "react-jsx",
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "resolveJsonModule": true,
13 | "moduleResolution": "node",
14 | "types": ["vite/client", "node"],
15 | "noFallthroughCasesInSwitch": true,
16 | "allowSyntheticDefaultImports": true,
17 | "lib": ["dom", "dom.iterable", "esnext"],
18 | "forceConsistentCasingInFileNames": true,
19 | "typeRoots": ["./src/global.d.ts", "node_modules/@types"],
20 | "paths": {
21 | "@root/*": ["./*"],
22 | "@src/*": ["src/*"],
23 | "@assets/*": ["src/assets/*"],
24 | "@pages/*": ["src/pages/*"],
25 | "virtual:reload-on-update-in-background-script": ["./src/global.d.ts"],
26 | "virtual:reload-on-update-in-view": ["./src/global.d.ts"]
27 | }
28 | },
29 | "include": ["src", "utils", "vite.config.ts", "node_modules/@types"]
30 | }
31 |
--------------------------------------------------------------------------------
/utils/log.ts:
--------------------------------------------------------------------------------
1 | type ColorType = "success" | "info" | "error" | "warning" | keyof typeof COLORS;
2 | type ValueOf = T[keyof T];
3 |
4 | export default function colorLog(message: string, type: ColorType) {
5 | let color: ValueOf;
6 |
7 | switch (type) {
8 | case "success":
9 | color = COLORS.FgGreen;
10 | break;
11 | case "info":
12 | color = COLORS.FgBlue;
13 | break;
14 | case "error":
15 | color = COLORS.FgRed;
16 | break;
17 | case "warning":
18 | color = COLORS.FgYellow;
19 | break;
20 | default:
21 | color = COLORS[type];
22 | break;
23 | }
24 |
25 | console.log(color, message);
26 | }
27 |
28 | const COLORS = {
29 | Reset: "\x1b[0m",
30 | Bright: "\x1b[1m",
31 | Dim: "\x1b[2m",
32 | Underscore: "\x1b[4m",
33 | Blink: "\x1b[5m",
34 | Reverse: "\x1b[7m",
35 | Hidden: "\x1b[8m",
36 | FgBlack: "\x1b[30m",
37 | FgRed: "\x1b[31m",
38 | FgGreen: "\x1b[32m",
39 | FgYellow: "\x1b[33m",
40 | FgBlue: "\x1b[34m",
41 | FgMagenta: "\x1b[35m",
42 | FgCyan: "\x1b[36m",
43 | FgWhite: "\x1b[37m",
44 | BgBlack: "\x1b[40m",
45 | BgRed: "\x1b[41m",
46 | BgGreen: "\x1b[42m",
47 | BgYellow: "\x1b[43m",
48 | BgBlue: "\x1b[44m",
49 | BgMagenta: "\x1b[45m",
50 | BgCyan: "\x1b[46m",
51 | BgWhite: "\x1b[47m",
52 | } as const;
53 |
--------------------------------------------------------------------------------
/utils/manifest-parser/index.ts:
--------------------------------------------------------------------------------
1 | type Manifest = chrome.runtime.ManifestV3;
2 |
3 | class ManifestParser {
4 | // eslint-disable-next-line @typescript-eslint/no-empty-function
5 | private constructor() {}
6 |
7 | static convertManifestToString(manifest: Manifest): string {
8 | if (process.env.__FIREFOX__) {
9 | manifest = this.convertToFirefoxCompatibleManifest(manifest);
10 | }
11 | return JSON.stringify(manifest, null, 2);
12 | }
13 |
14 | static convertToFirefoxCompatibleManifest(manifest: Manifest) {
15 | const manifestCopy = {
16 | ...manifest,
17 | } as { [key: string]: unknown };
18 |
19 | manifestCopy.background = {
20 | scripts: [manifest.background?.service_worker],
21 | type: "module",
22 | };
23 | manifestCopy.options_ui = {
24 | page: manifest.options_page,
25 | browser_style: false,
26 | };
27 | manifestCopy.content_security_policy = {
28 | extension_pages: "script-src 'self'; object-src 'self'",
29 | };
30 | delete manifestCopy.options_page;
31 | return manifestCopy as Manifest;
32 | }
33 | }
34 |
35 | export default ManifestParser;
36 |
--------------------------------------------------------------------------------
/utils/plugins/add-hmr.ts:
--------------------------------------------------------------------------------
1 | import * as path from "path";
2 | import { readFileSync } from "fs";
3 | import type { PluginOption } from "vite";
4 |
5 | const isDev = process.env.__DEV__ === "true";
6 |
7 | const DUMMY_CODE = `export default function(){};`;
8 |
9 | function getInjectionCode(fileName: string): string {
10 | return readFileSync(
11 | path.resolve(__dirname, "..", "reload", "injections", fileName),
12 | { encoding: "utf8" },
13 | );
14 | }
15 |
16 | type Config = {
17 | background?: boolean;
18 | view?: boolean;
19 | };
20 |
21 | export default function addHmr(config?: Config): PluginOption {
22 | const { background = false, view = true } = config || {};
23 | const idInBackgroundScript = "virtual:reload-on-update-in-background-script";
24 | const idInView = "virtual:reload-on-update-in-view";
25 |
26 | const scriptHmrCode = isDev ? getInjectionCode("script.js") : DUMMY_CODE;
27 | const viewHmrCode = isDev ? getInjectionCode("view.js") : DUMMY_CODE;
28 |
29 | return {
30 | name: "add-hmr",
31 | resolveId(id) {
32 | if (id === idInBackgroundScript || id === idInView) {
33 | return getResolvedId(id);
34 | }
35 | },
36 | load(id) {
37 | if (id === getResolvedId(idInBackgroundScript)) {
38 | return background ? scriptHmrCode : DUMMY_CODE;
39 | }
40 |
41 | if (id === getResolvedId(idInView)) {
42 | return view ? viewHmrCode : DUMMY_CODE;
43 | }
44 | },
45 | };
46 | }
47 |
48 | function getResolvedId(id: string) {
49 | return "\0" + id;
50 | }
51 |
--------------------------------------------------------------------------------
/utils/plugins/custom-dynamic-import.ts:
--------------------------------------------------------------------------------
1 | import type { PluginOption } from "vite";
2 |
3 | export default function customDynamicImport(): PluginOption {
4 | return {
5 | name: "custom-dynamic-import",
6 | renderDynamicImport({ moduleId }) {
7 | if (!moduleId.includes("node_modules") && process.env.__FIREFOX__) {
8 | return {
9 | left: `import(browser.runtime.getURL('./') + `,
10 | right: ".split('../').join(''));",
11 | };
12 | }
13 | return {
14 | left: "import(",
15 | right: ")",
16 | };
17 | },
18 | };
19 | }
20 |
--------------------------------------------------------------------------------
/utils/plugins/inline-vite-preload-script.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * solution for multiple content scripts
3 | * https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/issues/177#issuecomment-1784112536
4 | */
5 | export default function inlineVitePreloadScript() {
6 | let __vitePreload = "";
7 | return {
8 | name: "replace-vite-preload-script-plugin",
9 | // @ts-expect-error: vite types are not up-to-date
10 | async renderChunk(code, chunk, options, meta) {
11 | if (!/content/.test(chunk.fileName.toLowerCase())) {
12 | return null;
13 | }
14 | const chunkName: string | undefined = Object.keys(meta.chunks).find(
15 | (key) => /preload/.test(key),
16 | );
17 | if (!chunkName) {
18 | return null;
19 | }
20 | const modules = meta.chunks[chunkName].modules;
21 | console.log(modules);
22 | if (!__vitePreload) {
23 | __vitePreload = modules[Object.keys(modules)[0]].code;
24 | __vitePreload = __vitePreload.replaceAll("const ", "var ");
25 | }
26 | return {
27 | code: __vitePreload + code.split(`\n`).slice(1).join(`\n`),
28 | };
29 | },
30 | };
31 | }
32 |
--------------------------------------------------------------------------------
/utils/plugins/make-manifest.ts:
--------------------------------------------------------------------------------
1 | import * as fs from "fs";
2 | import * as path from "path";
3 | import colorLog from "../log";
4 | import ManifestParser from "../manifest-parser";
5 | import type { PluginOption } from "vite";
6 | import url from "url";
7 | import * as process from "process";
8 |
9 | const { resolve } = path;
10 |
11 | const rootDir = resolve(__dirname, "..", "..");
12 | const distDir = resolve(rootDir, "dist");
13 | const manifestFile = resolve(rootDir, "manifest.js");
14 |
15 | const getManifestWithCacheBurst = (): Promise<{
16 | default: chrome.runtime.ManifestV3;
17 | }> => {
18 | const withCacheBurst = (path: string) => `${path}?${Date.now().toString()}`;
19 | /**
20 | * In Windows, import() doesn't work without file:// protocol.
21 | * So, we need to convert path to file:// protocol. (url.pathToFileURL)
22 | */
23 | if (process.platform === "win32") {
24 | return import(withCacheBurst(url.pathToFileURL(manifestFile).href));
25 | }
26 | return import(withCacheBurst(manifestFile));
27 | };
28 |
29 | export default function makeManifest(config: {
30 | contentScriptCssKey?: string;
31 | }): PluginOption {
32 | const { contentScriptCssKey } = config;
33 | function makeManifest(manifest: chrome.runtime.ManifestV3, to: string) {
34 | if (!fs.existsSync(to)) {
35 | fs.mkdirSync(to);
36 | }
37 | const manifestPath = resolve(to, "manifest.json");
38 |
39 | // Naming change for cache invalidation
40 | if (contentScriptCssKey) {
41 | manifest?.content_scripts?.forEach((script) => {
42 | script.css = script?.css?.map((css) =>
43 | css.replace("", contentScriptCssKey),
44 | );
45 | });
46 | }
47 |
48 | fs.writeFileSync(
49 | manifestPath,
50 | ManifestParser.convertManifestToString(manifest),
51 | );
52 |
53 | colorLog(`Manifest file copy complete: ${manifestPath}`, "success");
54 | }
55 |
56 | return {
57 | name: "make-manifest",
58 | buildStart() {
59 | this.addWatchFile(manifestFile);
60 | },
61 | async writeBundle() {
62 | const manifest = await getManifestWithCacheBurst();
63 | makeManifest(manifest.default, distDir);
64 | },
65 | };
66 | }
67 |
--------------------------------------------------------------------------------
/utils/plugins/watch-rebuild.ts:
--------------------------------------------------------------------------------
1 | import type { PluginOption } from "vite";
2 | import { WebSocket } from "ws";
3 | import MessageInterpreter from "../reload/interpreter";
4 | import { LOCAL_RELOAD_SOCKET_URL } from "../reload/constant";
5 |
6 | export default function watchRebuild(config: {
7 | whenWriteBundle: () => void;
8 | }): PluginOption {
9 | const ws = new WebSocket(LOCAL_RELOAD_SOCKET_URL);
10 | return {
11 | name: "watch-rebuild",
12 | writeBundle() {
13 | /**
14 | * When the build is complete, send a message to the reload server.
15 | * The reload server will send a message to the client to reload or refresh the extension.
16 | */
17 | ws.send(MessageInterpreter.send({ type: "build_complete" }));
18 | config.whenWriteBundle();
19 | },
20 | };
21 | }
22 |
--------------------------------------------------------------------------------
/utils/reload/constant.ts:
--------------------------------------------------------------------------------
1 | export const LOCAL_RELOAD_SOCKET_PORT = 8082;
2 | export const LOCAL_RELOAD_SOCKET_URL = `ws://localhost:${LOCAL_RELOAD_SOCKET_PORT}`;
3 |
--------------------------------------------------------------------------------
/utils/reload/initReloadClient.ts:
--------------------------------------------------------------------------------
1 | import { LOCAL_RELOAD_SOCKET_URL } from "./constant";
2 | import MessageInterpreter from "./interpreter";
3 |
4 | let needToUpdate = false;
5 |
6 | export default function initReloadClient({
7 | watchPath,
8 | onUpdate,
9 | onForceReload,
10 | }: {
11 | watchPath: string;
12 | onUpdate: () => void;
13 | onForceReload?: () => void;
14 | }): WebSocket {
15 | const socket = new WebSocket(LOCAL_RELOAD_SOCKET_URL);
16 |
17 | function sendUpdateCompleteMessage() {
18 | socket.send(MessageInterpreter.send({ type: "done_update" }));
19 | }
20 |
21 | socket.addEventListener("message", (event) => {
22 | const message = MessageInterpreter.receive(String(event.data));
23 |
24 | switch (message.type) {
25 | case "do_update": {
26 | if (needToUpdate) {
27 | sendUpdateCompleteMessage();
28 | needToUpdate = false;
29 | onUpdate();
30 | }
31 | return;
32 | }
33 | case "wait_update": {
34 | if (!needToUpdate) {
35 | needToUpdate = message.path.includes(watchPath);
36 | }
37 | return;
38 | }
39 | case "force_reload": {
40 | onForceReload?.();
41 | return;
42 | }
43 | }
44 | });
45 |
46 | socket.onclose = () => {
47 | console.log(
48 | `Reload server disconnected.\nPlease check if the WebSocket server is running properly on ${LOCAL_RELOAD_SOCKET_URL}. This feature detects changes in the code and helps the browser to reload the extension or refresh the current tab.`,
49 | );
50 | setTimeout(() => {
51 | initReloadClient({ watchPath, onUpdate });
52 | }, 1000);
53 | };
54 |
55 | return socket;
56 | }
57 |
--------------------------------------------------------------------------------
/utils/reload/initReloadServer.ts:
--------------------------------------------------------------------------------
1 | import { WebSocket, WebSocketServer } from "ws";
2 | import chokidar from "chokidar";
3 | import { LOCAL_RELOAD_SOCKET_PORT, LOCAL_RELOAD_SOCKET_URL } from "./constant";
4 | import MessageInterpreter from "./interpreter";
5 | import { debounce } from "./utils";
6 |
7 | const clientsThatNeedToUpdate: Set = new Set();
8 | let needToForceReload = false;
9 |
10 | function initReloadServer() {
11 | const wss = new WebSocketServer({ port: LOCAL_RELOAD_SOCKET_PORT });
12 |
13 | wss.on("listening", () =>
14 | console.log(`[HRS] Server listening at ${LOCAL_RELOAD_SOCKET_URL}`),
15 | );
16 |
17 | wss.on("connection", (ws) => {
18 | clientsThatNeedToUpdate.add(ws);
19 |
20 | ws.addEventListener("close", () => clientsThatNeedToUpdate.delete(ws));
21 | ws.addEventListener("message", (event) => {
22 | if (typeof event.data !== "string") return;
23 |
24 | const message = MessageInterpreter.receive(event.data);
25 |
26 | if (message.type === "done_update") {
27 | ws.close();
28 | }
29 | if (message.type === "build_complete") {
30 | clientsThatNeedToUpdate.forEach((ws: WebSocket) =>
31 | ws.send(MessageInterpreter.send({ type: "do_update" })),
32 | );
33 | if (needToForceReload) {
34 | needToForceReload = false;
35 | clientsThatNeedToUpdate.forEach((ws: WebSocket) =>
36 | ws.send(MessageInterpreter.send({ type: "force_reload" })),
37 | );
38 | }
39 | }
40 | });
41 | });
42 | }
43 |
44 | /** CHECK:: src file was updated **/
45 | const debounceSrc = debounce(function (path: string) {
46 | // Normalize path on Windows
47 | const pathConverted = path.replace(/\\/g, "/");
48 | clientsThatNeedToUpdate.forEach((ws: WebSocket) =>
49 | ws.send(
50 | MessageInterpreter.send({ type: "wait_update", path: pathConverted }),
51 | ),
52 | );
53 | }, 100);
54 | chokidar
55 | .watch("src", { ignorePermissionErrors: true })
56 | .on("all", (_, path) => debounceSrc(path));
57 |
58 | /** CHECK:: manifest.js was updated **/
59 | chokidar
60 | .watch("manifest.js", { ignorePermissionErrors: true })
61 | .on("all", () => {
62 | needToForceReload = true;
63 | });
64 |
65 | initReloadServer();
66 |
--------------------------------------------------------------------------------
/utils/reload/injections/script.ts:
--------------------------------------------------------------------------------
1 | import initReloadClient from "../initReloadClient";
2 |
3 | export default function addHmrIntoScript(watchPath: string) {
4 | const reload = () => {
5 | chrome.runtime.reload();
6 | };
7 |
8 | initReloadClient({
9 | watchPath,
10 | onUpdate: reload,
11 | onForceReload: reload,
12 | });
13 | }
14 |
--------------------------------------------------------------------------------
/utils/reload/injections/view.ts:
--------------------------------------------------------------------------------
1 | import initReloadClient from "../initReloadClient";
2 |
3 | export default function addHmrIntoView(watchPath: string) {
4 | let pendingReload = false;
5 |
6 | initReloadClient({
7 | watchPath,
8 | onUpdate: () => {
9 | // disable reload when tab is hidden
10 | if (document.hidden) {
11 | pendingReload = true;
12 | return;
13 | }
14 | reload();
15 | },
16 | });
17 |
18 | // reload
19 | function reload(): void {
20 | pendingReload = false;
21 | window.location.reload();
22 | }
23 |
24 | // reload when tab is visible
25 | function reloadWhenTabIsVisible(): void {
26 | !document.hidden && pendingReload && reload();
27 | }
28 | document.addEventListener("visibilitychange", reloadWhenTabIsVisible);
29 | }
30 |
--------------------------------------------------------------------------------
/utils/reload/interpreter/index.ts:
--------------------------------------------------------------------------------
1 | import type { WebSocketMessage, SerializedMessage } from "./types";
2 |
3 | export default class MessageInterpreter {
4 | // eslint-disable-next-line @typescript-eslint/no-empty-function
5 | private constructor() {}
6 |
7 | static send(message: WebSocketMessage): SerializedMessage {
8 | return JSON.stringify(message);
9 | }
10 | static receive(serializedMessage: SerializedMessage): WebSocketMessage {
11 | return JSON.parse(serializedMessage);
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/utils/reload/interpreter/types.ts:
--------------------------------------------------------------------------------
1 | type UpdatePendingMessage = {
2 | type: "wait_update";
3 | path: string;
4 | };
5 | type UpdateRequestMessage = {
6 | type: "do_update";
7 | };
8 | type UpdateCompleteMessage = { type: "done_update" };
9 | type BuildCompletionMessage = { type: "build_complete" };
10 | type ForceReloadMessage = { type: "force_reload" };
11 |
12 | export type SerializedMessage = string;
13 | export type WebSocketMessage =
14 | | UpdateCompleteMessage
15 | | UpdateRequestMessage
16 | | UpdatePendingMessage
17 | | BuildCompletionMessage
18 | | ForceReloadMessage;
19 |
--------------------------------------------------------------------------------
/utils/reload/rollup.config.mjs:
--------------------------------------------------------------------------------
1 | import typescript from "@rollup/plugin-typescript";
2 |
3 | const plugins = [typescript()];
4 |
5 | export default [
6 | {
7 | plugins,
8 | input: "utils/reload/initReloadServer.ts",
9 | output: {
10 | file: "utils/reload/initReloadServer.js",
11 | },
12 | external: ["ws", "chokidar", "timers"],
13 | },
14 | {
15 | plugins,
16 | input: "utils/reload/injections/script.ts",
17 | output: {
18 | file: "utils/reload/injections/script.js",
19 | },
20 | },
21 | {
22 | plugins,
23 | input: "utils/reload/injections/view.ts",
24 | output: {
25 | file: "utils/reload/injections/view.js",
26 | },
27 | },
28 | ];
29 |
--------------------------------------------------------------------------------
/utils/reload/utils.ts:
--------------------------------------------------------------------------------
1 | import { clearTimeout } from "timers";
2 |
3 | export function debounce(
4 | callback: (...args: A) => void,
5 | delay: number,
6 | ) {
7 | let timer: NodeJS.Timeout;
8 | return function (...args: A) {
9 | clearTimeout(timer);
10 | timer = setTimeout(() => callback(...args), delay);
11 | };
12 | }
13 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import path, { resolve } from "path";
4 | import makeManifest from "./utils/plugins/make-manifest";
5 | import customDynamicImport from "./utils/plugins/custom-dynamic-import";
6 | import addHmr from "./utils/plugins/add-hmr";
7 | import inlineVitePreloadScript from "./utils/plugins/inline-vite-preload-script";
8 |
9 | const rootDir = resolve(__dirname);
10 | const srcDir = resolve(rootDir, "src");
11 | const pagesDir = resolve(srcDir, "pages");
12 | const assetsDir = resolve(srcDir, "assets");
13 | const outDir = resolve(rootDir, "dist");
14 | const publicDir = resolve(rootDir, "public");
15 |
16 | const isDev = process.env.__DEV__ === "true";
17 | const isProduction = !isDev;
18 |
19 | // ENABLE HMR IN BACKGROUND SCRIPT
20 | const enableHmrInBackgroundScript = true;
21 |
22 | export default defineConfig({
23 | resolve: {
24 | alias: {
25 | "@root": rootDir,
26 | "@src": srcDir,
27 | "@assets": assetsDir,
28 | "@pages": pagesDir,
29 | },
30 | },
31 | plugins: [
32 | makeManifest({}),
33 | react(),
34 | customDynamicImport(),
35 | addHmr({ background: enableHmrInBackgroundScript, view: true }),
36 | inlineVitePreloadScript(),
37 | ],
38 | publicDir,
39 | build: {
40 | outDir,
41 | /** Can slow down build speed. */
42 | // sourcemap: isDev,
43 | minify: isProduction,
44 | modulePreload: false,
45 | reportCompressedSize: isProduction,
46 | emptyOutDir: !isDev,
47 | rollupOptions: {
48 | input: {
49 | devtools: resolve(pagesDir, "devtools", "index.html"),
50 | panel: resolve(pagesDir, "panel", "index.html"),
51 | background: resolve(pagesDir, "background", "index.ts"),
52 | content: resolve(pagesDir, "content", "index.ts"),
53 | contentStyleGlobal: resolve(pagesDir, "content", "style.global.scss"),
54 | contentStyle: resolve(pagesDir, "content", "style.scss"),
55 | contentInjected: resolve(pagesDir, "content/mainWorld", "index.ts"),
56 | permission: resolve(pagesDir, "permission", "index.html"),
57 | popup: resolve(pagesDir, "popup", "index.html"),
58 | newtab: resolve(pagesDir, "newtab", "index.html"),
59 | options: resolve(pagesDir, "options", "index.html"),
60 | sidepanel: resolve(pagesDir, "sidepanel", "index.html"),
61 | },
62 | output: {
63 | entryFileNames: "src/pages/[name]/index.js",
64 | chunkFileNames: isDev
65 | ? "assets/js/[name].js"
66 | : "assets/js/[name].[hash].js",
67 | assetFileNames: (assetInfo) => {
68 | const { name, ext } = path.parse(assetInfo.name ?? "");
69 | if (isFont(ext)) {
70 | return `assets/fonts/${name}${ext}`;
71 | }
72 | return `assets/[ext]/[name].[ext]`;
73 | },
74 | },
75 | },
76 | },
77 | });
78 |
79 | function isFont(ext: string): boolean {
80 | return /^\.(woff2?|eot|ttf|otf)$/.test(ext);
81 | }
82 |
--------------------------------------------------------------------------------