visgl / react-google-maps

React components and hooks for the Google Maps JavaScript API
https://visgl.github.io/react-google-maps/
MIT License
1.27k stars 105 forks source link

[Bug] Refused to load remote script in chrome extension script #535

Open andriy-panchiy opened 1 month ago

andriy-panchiy commented 1 month ago

Description

Refused to load the script 'https://maps.googleapis.com/maps/api/js?key=secret&language=en-US&solution_channel=GMP_visgl_rgmlibrary_v1_default&loading=async&callback=__googleMapsCallback__' because it violates the following Content Security Policy directive: "script-src 'self'". Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback.

failed to load the Google Maps JavaScript API Error: The Google Maps JavaScript API could not load. at scriptElement.onerror (index.umd.js:182:1) ### Steps to Reproduce 1. init GoogleAPIMapsProvider in chrome extension ### Environment - Library version: @vis.gl/react-google-maps@1.1.3 - Google maps version: weekly - Browser and Version: Version 128.0.6613.138 (Official Build) (64-bit) - OS: Windows 10 ### Logs _No response_
usefulthink commented 1 month ago

I'm not an expert on CSP issues, but I think this could help: https://developers.google.com/maps/documentation/javascript/content-security-policy

Note that we will reuse the nonce-value of the first script-tag that has one:

https://github.com/visgl/react-google-maps/blob/40b47d4894cfbea1edfc627513f7713db499eea5/src/libraries/google-maps-api-loader.ts#L149-L151

Maybe you can provide a link to the site where the problem occurs?

usefulthink commented 1 month ago

Just noticed the part about this being in a chrome extension.

I think I read somewhere that external scripts will no longer be supported in chrome extensions as of manifest version 3. Sadly, this also includes the google maps API.

EDIT Bad news: https://developer.chrome.com/docs/extensions/develop/migrate/remote-hosted-code

You might have to reach out to the Chrome Extensions DevRel folks to see if they can help you with what you want to achieve.

andriy-panchiy commented 1 month ago

if anyone is looking for an answer to a similar question, here's how I solved it:

  1. install package to support Google places types:

    npm i @types/google.maps -D

  2. add 'scripting' permission to your manifest.json file.

  3. In order not to download the .js file used in the library - we can download it even before the build version of the extension is created, this will allow you to bypass the problem of chrome policy regarding remote code For these purposes, this code was enough for me:

    pre-build.ts:

    import * as fs from 'fs';
    import config from './config/config.json';
    
    const apiKey = config.GOOGLE_MAPS_API_KEY;
    
    (async () => {
      const libraries = ['places'].join(',');
      const response = await fetch(`https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=${libraries}`);
      const data = await response.text();
    
      fs.writeFileSync('./src/inject/googlePlaces.js', data);
    })();
  4. In order to maintain the latest version of this file, I also added a run script to package.json: image

  5. Since my extension is written on the basis of React, I created a React hook to interact with the GooglePlacesAPI:

    useGooglePlaces.ts:

    import { useEffect } from 'react';
    import { useDebouncedCallback } from 'use-debounce';
    
    export type GooglePlacesAutocompleteHandle = {
      getSessionToken: () => google.maps.places.AutocompleteSessionToken | undefined;
      refreshSessionToken: () => void;
    };
    
    export interface LatLng {
      lat: number;
      lng: number;
    }
    
    export interface AutocompletionRequest {
      bounds?: [LatLng, LatLng];
      componentRestrictions?: { country: string | string[] };
      location?: LatLng;
      offset?: number;
      radius?: number;
      types?: string[];
    }
    
    export default interface GooglePlacesAutocompleteProps {
      autocompletionRequest?: AutocompletionRequest;
      debounce?: number;
      minLengthAutocomplete?: number;
      onLoadFailed?: (error: Error) => void;
      withSessionToken?: boolean;
    }
    
    export const useGooglePlacesAutocomplete = ({
      autocompletionRequest = {},
      debounce = 300,
      minLengthAutocomplete = 0,
      onLoadFailed = console.error,
      withSessionToken = false,
    }: GooglePlacesAutocompleteProps): ((value: string, cb: (options: google.maps.places.AutocompletePrediction[]) => void) => void) => {
      const [fetchSuggestions] = useDebouncedCallback(async (value: string, cb: (options: google.maps.places.AutocompletePrediction[]) => void) => {
        const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
        if (!tab?.id) return cb([]);
    
        const [res] = await chrome.scripting.executeScript({
          target: { tabId: tab.id },
          world: 'MAIN',
          func: async (value: string, minLengthAutocomplete: number, withSessionToken: boolean, autocompletionRequest: AutocompletionRequest): Promise<google.maps.places.AutocompletePrediction[]> => {
            if (!window.google) throw new Error('[react-google-places-autocomplete]: Google script not loaded');
            if (!window.google.maps) throw new Error('[react-google-places-autocomplete]: Google maps script not loaded');
            if (!window.google.maps.places) throw new Error('[react-google-places-autocomplete]: Google maps places script not loaded');
    
            const PlacesService = new google.maps.places.AutocompleteService();
            const SessionToken = new google.maps.places.AutocompleteSessionToken();
    
            console.log('value', value);
    
            if (value.length < minLengthAutocomplete) return [];
    
            const autocompletionRequestBuilder = (
              autocompletionRequest: AutocompletionRequest,
              input: string,
              sessionToken?: google.maps.places.AutocompleteSessionToken,
            ): google.maps.places.AutocompletionRequest => {
              const { bounds, location, componentRestrictions, offset, radius, types } = autocompletionRequest;
    
              const res: google.maps.places.AutocompletionRequest = {
                input,
                componentRestrictions,
                offset,
                radius,
                types,
                ...(sessionToken ? { sessionToken: SessionToken } : {}),
                ...(bounds ? { bounds: new google.maps.LatLngBounds(...bounds) } : {}),
                ...(location ? { location: new google.maps.LatLng(location) } : {}),
              };
    
              return res;
            };
    
            const waitPromise = <T>(promise: Promise<T>, timeout: number): Promise<T | Error> => {
              return Promise.race([promise, new Promise<Error>((_, reject) => setTimeout(() => reject(new Error('Timeout')), timeout))]);
            };
    
            const data = PlacesService.getPlacePredictions(autocompletionRequestBuilder(autocompletionRequest, value, withSessionToken && SessionToken));
            const res = await waitPromise(data, 5000);
            if (!(res instanceof Error)) return res.predictions;
    
            return [];
          },
          args: [value, minLengthAutocomplete, withSessionToken, autocompletionRequest],
        });
    
        if (res) {
          return cb(res.result);
        }
      }, debounce);
    
      const init = async () => {
        try {
          const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
          if (!tab?.id) return;
          if (!window.google || !window.google.maps || !window.google.maps.places) {
            await chrome.scripting.executeScript({
              target: { tabId: tab.id },
              world: 'MAIN',
              files: ['inject/googlePlaces.js'],
            });
          }
        } catch (error) {
          onLoadFailed(new Error(String(error)));
        }
      };
    
      useEffect(() => {
        init();
      }, []);
    
      return fetchSuggestions;
    };
  6. Usage:

    page.tsx:

    import { useGooglePlacesAutocomplete } from '@/library/hooks/useGooglePlaces';
    
    export const Example = (props) => {
      const [autocompleteData, setAutocompleteData] = useState<google.maps.places.AutocompletePrediction[]>([]);
      const autocomplete = useGooglePlacesAutocomplete({ debounce: 300, minLengthAutocomplete: 3 });
    
      return (
        <>
          <input
            type='text'
            onChange={(e) => autocomplete(e.target.value, setAutocompleteData)}
          />
          <ul>
            {autocompleteData.map((item, index) => <li key={index}>{item.description}</li>)}
          </ul>
        </>
      );
    };
  7. Enjoy