diff --git a/.gitignore b/.gitignore index fd5106f..ab62fc0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ .DS_STORE + +dist/ +*.zip + +# IDEs +.idea/ diff --git a/LICENSE.md b/LICENSE.md index 7bed554..a0a0400 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,4 +1,3 @@ - The MIT License (MIT) Copyright (c) 2013 Glenn 'devalias' Grant (http://devalias.net) diff --git a/TODO.md b/TODO.md deleted file mode 100644 index ff9c328..0000000 --- a/TODO.md +++ /dev/null @@ -1,10 +0,0 @@ -### TODO - -* LAUNCH! -* Document all functions with JSDoc -* Abstract the rest of the API stuff into seperate/reusable file -* Add an 'options' page with my info (name, website, blog, github, etc. donations?) - * Feedback/suggestions/comments/criticisms/binary love notes? => feedback@devalias.net - * Mention why I created this? (eg. For sesh, ping the developer or something?) - * New sesh with: tabs to right, current and tabs to right -* Allow moving tabs to right to specified window ('spin through all open windows, display as submenu') diff --git a/_locales/en/messages.json b/_locales/en/messages.json deleted file mode 100644 index 803bf03..0000000 --- a/_locales/en/messages.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - /* Localisation */ -} diff --git a/icons/NewWindowWithTabsToRight-Icon@128px.png b/icons/NewWindowWithTabsToRight-Icon@128px.png index 8ce792d..d941fae 100644 Binary files a/icons/NewWindowWithTabsToRight-Icon@128px.png and b/icons/NewWindowWithTabsToRight-Icon@128px.png differ diff --git a/icons/NewWindowWithTabsToRight-Icon@16px.png b/icons/NewWindowWithTabsToRight-Icon@16px.png index 6a968a9..52f37d8 100644 Binary files a/icons/NewWindowWithTabsToRight-Icon@16px.png and b/icons/NewWindowWithTabsToRight-Icon@16px.png differ diff --git a/icons/NewWindowWithTabsToRight-Icon@32px.png b/icons/NewWindowWithTabsToRight-Icon@32px.png new file mode 100644 index 0000000..5e5c04e Binary files /dev/null and b/icons/NewWindowWithTabsToRight-Icon@32px.png differ diff --git a/icons/NewWindowWithTabsToRight-Icon@48px.png b/icons/NewWindowWithTabsToRight-Icon@48px.png index d5702a1..91f9497 100644 Binary files a/icons/NewWindowWithTabsToRight-Icon@48px.png and b/icons/NewWindowWithTabsToRight-Icon@48px.png differ diff --git a/js/chromeExtensionApiAbstractions.js b/js/chromeExtensionApiAbstractions.js deleted file mode 100644 index 11ea014..0000000 --- a/js/chromeExtensionApiAbstractions.js +++ /dev/null @@ -1,232 +0,0 @@ -/** - * @overview A collection of Chrome Extension API Abstractions to make my life easier. - * @version 0.1.0 - * @author Glenn 'devalias' Grant - * @copyright Copyright (c) 2013 Glenn 'devalias' Grant (http://devalias.net) - * @license The MIT License (MIT) (see LICENSE.md) - */ - -// ---------------------------------------------------------- -// Chrome API -// ---------------------------------------------------------- - -/** - * Creates a new tab. - * @since 0.1.0 - * @name ChromeExtensionAPI~CreateTab - * @param {ChromeExtensionAPI~CreateTabOptions} createProperties - Options for the Create Tab method. - * @param {ChromeExtensionAPI~CreateTabCallback} callback - Callback for the Create Tab method. - * @see Chrome Extension API - Tabs - Create - */ -function createTab(createProperties, callback) { - chrome.tabs.create(createProperties, callback); -} - -/** - * Gets all tabs that have the specified properties, or all tabs if no properties are specified. - * @since 0.1.0 - * @name ChromeExtensionAPI~QueryTabs - * @param {ChromeExtensionAPI~QueryTabsOptions} queryInfo - Options for the Query Tabs method. - * @param {ChromeExtensionAPI~QueryTabsCallback} callback - Callback for the Query Tabs method. - * @see Chrome Extension API - Tabs - Query - */ -function queryTabs(queryInfo, callback) { - chrome.tabs.query(queryInfo, callback); -} - -/** - * Moves one or more tabs to a new position within its window, or to a new window. - * @since 0.1.0 - * @name ChromeExtensionAPI~MoveTabs - * @param {number|number[]} tabIds - The tab id or list of tab ids to move. - * @param {ChromeExtensionAPI~MoveTabsOptions} moveProperties - Options for the Move Tabs method. - * @param {ChromeExtensionAPI~MoveTabsCallback} callback - Callback for the Move Tabs method. - * @see Chrome Extension API - Tabs - Move - */ -function moveTabs(tabIds, moveProperties, callback) { - chrome.tabs.move(tabIds, moveProperties, callback); -} - -/** - * Creates (opens) a new browser with any optional sizing, position or default URL provided. - * @since 0.1.0 - * @name ChromeExtensionAPI~CreateWindow - * @param {ChromeExtensionAPI~CreateWindowOptions} createData - Options for the Create Window method. - * @param {ChromeExtensionAPI~CreateWindowCallback} callback - Callback for the Create Window method. - * @see Chrome Extension API - Windows - Create - */ -function createWindow(createData, callback) { - chrome.windows.create(createData, callback); -} - -// ---------------------------------------------------------- -// Chrome API - Abstractions -// ---------------------------------------------------------- - -/** Return an array of all tabs for the specified window id. - * @since 0.1.0 - * @param {string} url - The URL to open in the new tab. - * @param {boolean} makeActive - Whether the new tab should become active. - * @see createTab - */ -function createTabWithUrl(url, makeActive) -{ - makeActive = typeof makeActive !== 'undefined' ? makeActive : true; - createTab({ - "url": url, - "active": makeActive - }); -} - -/** Return an array of all tabs for the specified window id. - * @since 0.1.0 - * @param {number} windowId - ID of the window to get tabs from. - * @param {ChromeExtensionAPI~QueryTabsCallback} callback - Array of tabs for window. - * @see queryTabs - */ -function getTabsForWindowId(windowId, callback) { - queryTabs({ - "windowId": windowId - }, - callback); -} - -/** Return an array of all tabs for the parent window of supplied tab. - * @since 0.1.0 - * @param {ChromeExtensionAPI~Tab} tab - Tab to use the parent window of. - * @param {ChromeExtensionAPI~QueryTabsCallback} callback - Array of tabs for parent window. - * @see getTabsForWindowId - */ -function getTabsForParentWindowOfTab(tab, callback) { - getTabsForWindowId(tab.windowId, callback); -} - -/** Return an array of all tab ID's including and to the right of the supplied tab index. - * @since 0.1.0 - * @param {number} tabIndex - Get tabs including and right of this tab index. - * @param {ChromeExtensionAPI~Tab[]} tabs - An array of tab objects to extract the id's from. - * @returns {number[]} Array of tabId's - */ -function getIdsForCurrentAndTabsToRightOf(tabIndex, tabs) { - var tabIds = []; - for (var i = tabIndex; i < tabs.length; i++) { - tabIds[tabIds.length] = tabs[i].id; - } - return tabIds; -} - -/** Return an array of all tab ID's right of the supplied tab index (not including the supplied tab index). - * @since 0.1.0 - * @param {number} tabIndex - Get tabs right of this tab index. - * @param {ChromeExtensionAPI~Tab[]} tabs - An array of tab objects to extract the id's from. - * @returns {number[]} Array of tabId's - * @see getIdsForCurrentAndTabsToRightOf - */ -function getIdsForTabsToRightOf(tabIndex, tabs) { - return getIdsForCurrentAndTabsToRightOf(tabIndex+1, tabs); -} - -/** Create a new window and move the supplied tab ID's to it. - * @since 0.1.0 - * @param {number[]} tabIds - The id's of the tabs to moved. - * @param {ChromeExtensionAPI~MoveTabsCallback} callback - Details about the moved tabs - * @see ChromeExtensionAPI~CreateWindow - * @see moveTabs - */ -function createWindowWithTabs(tabIds, callback) { - if (!tabIds | tabIds.length < 1) - { - alert("There are no tabs to make a new window with."); - return; - } - - createWindow({"tabId": tabIds[0]}, function(newWindow) { - moveTabs(tabIds, {"windowId": newWindow.id, "index": -1}, callback); - }); -} - -// ---------------------------------------------------------- -// End Chrome API - Abstractions -// ---------------------------------------------------------- - -// ---------------------------------------------------------- -// Chrome Extension API - Typedef and Callback doco -// ---------------------------------------------------------- - -// --------- -// Windows -// --------- - -/** A Chrome Extension API - Windows - Window object - * @typedef {object} ChromeExtensionAPI~Window - * @see Chrome Extension API - Windows - Window - */ - -/** Options for Chrome Extension API - Windows - Create Windows method - * @typedef {object} ChromeExtensionAPI~CreateWindowOptions - * @see ChromeExtensionAPI~CreateWindow - * @see Chrome Extension API - Windows - Create Window Options - */ - -/** - * Callback function for Chrome Extension API - Windows - Create method - * @callback ChromeExtensionAPI~CreateWindowCallback - * @param {ChromeExtensionAPI~Window} window - Contains details about the created window - * @see ChromeExtensionAPI~CreateWindow - * @see Chrome Extension API - Windows - Create - */ - -// --------- -// Tabs -// --------- - -/** A Chrome Extension API - Tabs - Tab object - * @typedef {object} ChromeExtensionAPI~Tab - * @see Chrome Extension API - Tabs - Tab - */ - -/** Options for Chrome Extension API - Tabs - Create method - * @typedef {object} ChromeExtensionAPI~CreateTabOptions - * @see ChromeExtensionAPI~CreateTab - * @see Chrome Extension API - Tabs - Create - */ - -/** - * Callback function for Chrome Extension API - Tabs - Create method. - * @callback ChromeExtensionAPI~CreateTabCallback - * @param {ChromeExtensionAPI~Tab} tab - Details of the created tab. - * @see ChromeExtensionAPI~CreateTab - * @see Chrome Extension API - Tabs - Create - */ - -/** Options for Chrome Extension API - Tabs - Query method - * @typedef {object} ChromeExtensionAPI~QueryTabsOptions - * @see ChromeExtensionAPI~QueryTab - * @see Chrome Extension API - Tabs - Query - */ - -/** - * Callback function for Chrome Extension API - Tabs - Query method. - * @callback ChromeExtensionAPI~QueryTabsCallback - * @param {ChromeExtensionAPI~Tab[]} tabs - Results for the queried tabs. - * @see ChromeExtensionAPI~QueryTab - * @see Chrome Extension API - Tabs - Query - */ - -/** Options for Chrome Extension API - Tabs - Move method - * @typedef {object} ChromeExtensionAPI~MoveTabsOptions - * @see ChromeExtensionAPI~MoveTab - * @see Chrome Extension API - Tabs - Move - */ - -/** - * Callback function for Chrome Extension API - Tabs - Move method. - * @callback ChromeExtensionAPI~MoveTabsCallback - * @param {ChromeExtensionAPI~Tab|ChromeExtensionAPI~Tab[]} tabs - Details about the moved tabs. - * @see ChromeExtensionAPI~MoveTab - * @see Chrome Extension API - Tabs - Move - */ - -// ---------------------------------------------------------- -// End Chrome Extension API - Typedef and Callback doco -// ---------------------------------------------------------- diff --git a/js/googleAnalytics.js b/js/googleAnalytics.js deleted file mode 100644 index 415b49b..0000000 --- a/js/googleAnalytics.js +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @overview Google Analytics asynchronous load - * @see http://developer.chrome.com/extensions/tut_analytics.html - * @see https://developers.google.com/analytics/devguides/collection/gajs/eventTrackerGuide - */ - -var _gaq = _gaq || []; -_gaq.push(['_setAccount', 'UA-42943200-1']); // NOTE: Make sure you change this to your own Google Analytics code!!! -_gaq.push(['_trackPageview']); - -(function() { - var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true; - ga.src = 'https://ssl.google-analytics.com/ga.js'; - var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s); -})(); - - -// Note: This was the code provided when I setup Google Analytics. Newer version of? -// diff --git a/manifest.json b/manifest.json index 404abe8..1ae6ee1 100644 --- a/manifest.json +++ b/manifest.json @@ -1,42 +1,49 @@ { + "manifest_version": 3, + "minimum_chrome_version": "110", + "version": "2.0.0", "name": "New Window With Tabs To Right", - "version": "1.0.1", - "manifest_version": 2, "description": "This extension creates a new window with the tabs to the right of the currently selected tab.", "homepage_url": "http://devalias.net/dev/chrome-extensions/", - "content_security_policy": "script-src 'self' https://ssl.google-analytics.com; object-src 'self'", - "permissions": [ - "tabs", - "contextMenus" - ], "icons": { "16": "icons/NewWindowWithTabsToRight-Icon@16px.png", + "32": "icons/NewWindowWithTabsToRight-Icon@32px.png", "48": "icons/NewWindowWithTabsToRight-Icon@48px.png", "128": "icons/NewWindowWithTabsToRight-Icon@128px.png" }, - "default_locale": "en", + "permissions": [ + "contextMenus", + "storage" + ], + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'" + }, "background": { - "scripts": [ - "js/googleAnalytics.js", - "js/chromeExtensionApiAbstractions.js", - "src/bg/background.js" - ], - "persistent": true + "service_worker": "src/service_worker.js", + "type": "module" + }, + "action": { + "default_icon": { + "16": "icons/NewWindowWithTabsToRight-Icon@16px.png", + "32": "icons/NewWindowWithTabsToRight-Icon@32px.png", + "48": "icons/NewWindowWithTabsToRight-Icon@48px.png", + "128": "icons/NewWindowWithTabsToRight-Icon@128px.png" + } }, "commands": { - "newWindowWithCurrentAndTabsToRight": { - "suggested_key": { - "default": "Ctrl+Shift+Y", - "mac": "Command+Shift+Y" - }, - "description": "Create a new window with the current tab and tabs on the right." + "newWindowWithCurrentAndTabsToRight": { + "suggested_key": { + "default": "Ctrl+Shift+Y", + "mac": "Command+Shift+Y" + }, + "description": "Create a new window with the current tab and tabs on the right." + }, + "newWindowWithTabsToRight": { + "suggested_key": { + "default": "Ctrl+Shift+U", + "mac": "Command+Shift+U" }, - "newWindowWithTabsToRight": { - "suggested_key": { - "default": "Ctrl+Shift+U", - "mac": "Command+Shift+U" - }, - "description": "Create a new window with the tabs on the right." - } + "description": "Create a new window with the tabs on the right." + } } } diff --git a/scripts/build b/scripts/build new file mode 100755 index 0000000..89b5fd7 --- /dev/null +++ b/scripts/build @@ -0,0 +1,75 @@ +#!/usr/bin/env zsh + +# Capture script name +SCRIPT_NAME=$0 + +# Variables +DIST_DIR="dist" +ITEMS_TO_COPY=("README.md" "LICENSE.md" "manifest.json" "src" "icons") +EXTENSION_NAME="newWindowWithTabsToRight" + +# Function to show help +show_help() { + echo "Usage: $SCRIPT_NAME [options]" + echo "Options:" + echo " -h, --help Show this help message" +} + +# Function to read version from manifest.json using jq +read_version() { + if ! command -v jq &> /dev/null; then + echo "Error: jq is not installed." + exit 1 + fi + + VERSION=$(jq -r '.version' manifest.json) + if [ -z "$VERSION" ]; then + echo "Error: Could not read version from manifest.json" + exit 1 + fi +} + +# Parse options +while [[ "$#" -gt 0 ]]; do + case $1 in + -h|--help) show_help; exit 0;; + *) echo "Unknown option: $1"; show_help; exit 1;; + esac + shift +done + +# Read version +echo "Reading version from manifest.json..." +read_version +echo "Version: $VERSION" + +# Create zip filename +ZIP_FILE="${EXTENSION_NAME}-${VERSION}.zip" + +# Create the dist directory +echo "Creating dist directory..." +mkdir -p $DIST_DIR + +# Copy files and directories using rsync to exclude .DS_Store files +echo "Copying files and directories..." +for item in ${ITEMS_TO_COPY[@]}; do + if [ -e $item ]; then + rsync -a --exclude='.DS_Store' $item $DIST_DIR/ + echo " Copied: $item" + else + echo " Warning: $item does not exist." + fi +done + +# Create zip file +if [ -f $ZIP_FILE ]; then + echo "Removing existing zip file: $ZIP_FILE" + rm $ZIP_FILE +fi + +echo "Creating zip file: $ZIP_FILE" +cd $DIST_DIR +zip -r ../$ZIP_FILE ./* +cd .. + +echo "Build completed. Files are in the $DIST_DIR directory and zipped in $ZIP_FILE." diff --git a/scripts/clean b/scripts/clean new file mode 100755 index 0000000..1ff7a14 --- /dev/null +++ b/scripts/clean @@ -0,0 +1,48 @@ +#!/usr/bin/env zsh + +# Capture script name +SCRIPT_NAME=$0 + +# Variables +DIST_DIR="dist" +EXTENSION_NAME="newWindowWithTabsToRight" + +# Function to show help +show_help() { + echo "Usage: $SCRIPT_NAME [options]" + echo "Options:" + echo " -h, --help Show this help message" +} + +# Parse options +while [[ "$#" -gt 0 ]]; do + case $1 in + -h|--help) show_help; exit 0;; + *) echo "Unknown option: $1"; show_help; exit 1;; + esac + shift +done + +# Remove the dist directory +if [ -d ./$DIST_DIR ]; then + echo "Removing dist directory: $DIST_DIR" + rm -rf ./$DIST_DIR +else + echo "Dist directory does not exist: $DIST_DIR" +fi + +# Find and remove the zip files with NULL_GLOB enabled just for this line +ZIP_FILES=($(setopt NULL_GLOB; echo ./${EXTENSION_NAME}-*.zip)) + +# Remove the zip files +if [ ${#ZIP_FILES[@]} -gt 0 ]; then + echo "Removing zip files:" + for file in $ZIP_FILES; do + echo " $file" + rm -f "$file" + done +else + echo "No zip files found matching: ${EXTENSION_NAME}-*.zip" +fi + +echo "Cleanup completed. $DIST_DIR directory and matching zip files in the current directory have been removed." diff --git a/scripts/optimize-icons b/scripts/optimize-icons new file mode 100755 index 0000000..d53f97e --- /dev/null +++ b/scripts/optimize-icons @@ -0,0 +1,51 @@ +#!/usr/bin/env zsh + +# Capture script name +SCRIPT_NAME=$0 + +# Variables +ICONS_DIR="icons" + +# Function to show help +show_help() { + echo "Usage: $SCRIPT_NAME [options]" + echo "Options:" + echo " -h, --help Show this help message" +} + +# Parse options +while [[ "$#" -gt 0 ]]; do + case $1 in + -h|--help) show_help; exit 0;; + *) echo "Unknown option: $1"; show_help; exit 1;; + esac + shift +done + +# Check if oxipng is installed +if ! command -v oxipng &> /dev/null; then + echo "oxipng is not installed." + read "response?Would you like to install it with Homebrew? (y/n) " + if [[ "$response" == "y" || "$response" == "Y" ]]; then + if command -v brew &> /dev/null; then + echo "Installing oxipng with Homebrew..." + brew install oxipng + else + echo "Homebrew is not installed. Please install Homebrew first: https://brew.sh/" + exit 1 + fi + else + echo "Please install oxipng with Homebrew: brew install oxipng" + exit 1 + fi +fi + +# Optimize PNG files in the icons directory +if [ -d $ICONS_DIR ]; then + echo "Optimizing PNG files in the $ICONS_DIR directory..." + oxipng -o max --dir $ICONS_DIR --alpha $ICONS_DIR/*.png + echo "Optimization completed." +else + echo "Icons directory does not exist: $ICONS_DIR" + exit 1 +fi diff --git a/src/.DS_Store b/src/.DS_Store deleted file mode 100644 index 5008ddf..0000000 Binary files a/src/.DS_Store and /dev/null differ diff --git a/src/bg/background.html b/src/bg/background.html deleted file mode 100644 index 1ce1e93..0000000 --- a/src/bg/background.html +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/bg/background.js b/src/bg/background.js deleted file mode 100644 index 751c7c9..0000000 --- a/src/bg/background.js +++ /dev/null @@ -1,85 +0,0 @@ -/** - * @overview Handles the background functionality for the 'New Window With Tabs To Right' extension. - * @version 0.0.1 - * @author Glenn 'devalias' Grant - * @copyright Copyright (c) 2013 Glenn 'devalias' Grant (http://devalias.net) - * @license The MIT License (MIT) (see LICENSE.md) - */ - -// Menu Actions -function newWindowWithTabsToRight(info, currentTab) { - _gaq.push(['_trackEvent', 'contextMenu', 'clicked', 'newWindowWithTabsToRight']); - getTabsForParentWindowOfTab(currentTab, function(tabs) { - var tabIds = getIdsForTabsToRightOf(currentTab.index, tabs); - createWindowWithTabs(tabIds, function(tabs) { - // TODO: Maybe ensure tabs were moved? - }); - }); -} - -function newWindowWithCurrentAndTabsToRight(info, currentTab) { - _gaq.push(['_trackEvent', 'contextMenu', 'clicked', 'newWindowWithCurrentAndTabsToRight']); - getTabsForParentWindowOfTab(currentTab, function(tabs) { - var tabIds = getIdsForCurrentAndTabsToRightOf(currentTab.index, tabs); - createWindowWithTabs(tabIds, function(tabs) { - // TODO: Maybe ensure tabs were moved? - }); - }); -} - -function aboutTheDeveloper(info, currentTab) { - _gaq.push(['_trackEvent', 'contextMenu', 'clicked', 'aboutTheDeveloper']); - createTabWithUrl("http://devalias.net/dev/chrome-extensions/new-window-with-tabs-to-right/", true); -} - -// Keybinding handlers -chrome.commands.onCommand.addListener(function(command) { - var qOptions = {currentWindow: true, active: true} - chrome.tabs.query(qOptions, function(arrayOfTabs) { - var curTab = arrayOfTabs[0]; - if (command == "newWindowWithTabsToRight") { - newWindowWithTabsToRight(qOptions, curTab); - } - else if (command == "newWindowWithCurrentAndTabsToRight") { - newWindowWithCurrentAndTabsToRight(qOptions, curTab); - } - }); -}); - -// Menu -// TODO: Abstract the chrome API stuff into library -var menuRoot = chrome.contextMenus.create({ - "type": "normal", - "title": "New window with..", - "contexts": ["page"] -}); - -var menuWithTabsToRight = chrome.contextMenus.create({ - "type": "normal", - "parentId": menuRoot, - "title": "..tabs to right", - "contexts": ["page"], - "onclick": newWindowWithTabsToRight -}); - -var menuWithThisTabAndTabsToRight = chrome.contextMenus.create({ - "type": "normal", - "parentId": menuRoot, - "title": "..this tab and tabs to right", - "contexts": ["page"], - "onclick": newWindowWithCurrentAndTabsToRight -}); - -var menuSeparator = chrome.contextMenus.create({ - "type": "separator", - "parentId": menuRoot, - "contexts": ["page"], -}); - -var menuAboutTheDeveloper = chrome.contextMenus.create({ - "type": "normal", - "parentId": menuRoot, - "title": "About the Developer", - "contexts": ["page"], - "onclick": aboutTheDeveloper -}); diff --git a/src/google-analytics.js b/src/google-analytics.js new file mode 100644 index 0000000..4c5d837 --- /dev/null +++ b/src/google-analytics.js @@ -0,0 +1,127 @@ +// See: +// https://developer.chrome.com/docs/extensions/how-to/integrate/google-analytics-4 +// https://developers.google.com/analytics/devguides/collection/protocol/ga4 +// https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events?client_type=gtag +// https://github.com/GoogleChrome/chrome-extensions-samples/tree/main/functional-samples/tutorial.google-analytics +// https://github.com/GoogleChrome/chrome-extensions-samples/blob/main/functional-samples/tutorial.google-analytics/scripts/google-analytics.js +// https://github.com/GoogleChrome/chrome-extensions-samples/blob/main/functional-samples/tutorial.google-analytics/service-worker.js + +const GA_ENDPOINT = 'https://www.google-analytics.com/mp/collect'; +const GA_DEBUG_ENDPOINT = 'https://www.google-analytics.com/debug/mp/collect'; + +// Get via https://analytics.google.com/analytics/web/ +const MEASUREMENT_ID = 'G-BCGJGLETYW'; +const API_SECRET = '_xmXa00ATSmnNWs2J2xadQ'; +const DEFAULT_ENGAGEMENT_TIME_MSEC = 100; + +// Duration of inactivity after which a new session is created +const SESSION_EXPIRATION_IN_MIN = 30; + +class Analytics { + constructor(debug = false) { + this.debug = debug; + } + + // Returns the client id, or creates a new one if one doesn't exist. + // Stores client id in local storage to keep the same client id as long as + // the extension is installed. + async getOrCreateClientId() { + let { clientId } = await chrome.storage.local.get('clientId'); + if (!clientId) { + // Generate a unique client ID, the actual value is not relevant + clientId = self.crypto.randomUUID(); + await chrome.storage.local.set({ clientId }); + } + return clientId; + } + + // Returns the current session id, or creates a new one if one doesn't exist or + // the previous one has expired. + async getOrCreateSessionId() { + // Use storage.session because it is only in memory + let { sessionData } = await chrome.storage.session.get('sessionData'); + const currentTimeInMs = Date.now(); + // Check if session exists and is still valid + if (sessionData && sessionData.timestamp) { + // Calculate how long ago the session was last updated + const durationInMin = (currentTimeInMs - sessionData.timestamp) / 60000; + // Check if last update lays past the session expiration threshold + if (durationInMin > SESSION_EXPIRATION_IN_MIN) { + // Clear old session id to start a new session + sessionData = null; + } else { + // Update timestamp to keep session alive + sessionData.timestamp = currentTimeInMs; + await chrome.storage.session.set({ sessionData }); + } + } + if (!sessionData) { + // Create and store a new session + sessionData = { + session_id: currentTimeInMs.toString(), + timestamp: currentTimeInMs.toString() + }; + await chrome.storage.session.set({ sessionData }); + } + return sessionData.session_id; + } + + // Fires an event with optional params. Event names must only include letters and underscores. + async fireEvent(name, params = {}) { + // Configure session id and engagement time if not present, for more details see: + // https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events?client_type=gtag#recommended_parameters_for_reports + if (!params.session_id) { + params.session_id = await this.getOrCreateSessionId(); + } + if (!params.engagement_time_msec) { + params.engagement_time_msec = DEFAULT_ENGAGEMENT_TIME_MSEC; + } + + try { + const response = await fetch( + `${ + this.debug ? GA_DEBUG_ENDPOINT : GA_ENDPOINT + }?measurement_id=${MEASUREMENT_ID}&api_secret=${API_SECRET}`, + { + method: 'POST', + body: JSON.stringify({ + client_id: await this.getOrCreateClientId(), + events: [ + { + name, + params + } + ] + }) + } + ); + if (!this.debug) { + return; + } + console.log(await response.text()); + } catch (e) { + console.error('Google Analytics request failed with an exception', e); + } + } + + // Fire a page view event. + async firePageViewEvent(pageTitle, pageLocation, additionalParams = {}) { + return this.fireEvent('page_view', { + page_title: pageTitle, + page_location: pageLocation, + ...additionalParams + }); + } + + // Fire an error event. + async fireErrorEvent(error, additionalParams = {}) { + // Note: 'error' is a reserved event name and cannot be used + // see https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference?client_type=gtag#reserved_names + return this.fireEvent('extension_error', { + ...error, + ...additionalParams + }); + } +} + +export default new Analytics(); diff --git a/src/service_worker.js b/src/service_worker.js new file mode 100644 index 0000000..f0b904e --- /dev/null +++ b/src/service_worker.js @@ -0,0 +1,278 @@ +import Analytics from './google-analytics.js'; +import { CONTEXT_MENU_SETTINGS_KEYS, STORAGE_KEYS, getSettings, setSettings } from "./settings.js"; + +const ABOUT_THE_DEVELOPER_URL = 'https://www.devalias.net/dev/chrome-extensions/new-window-with-tabs-to-right/'; + +/** + * Fired when the extension is first installed, when the extension is updated to a new version, and when Chrome is + * updated to a new version. + * + * @see {@link https://developer.chrome.com/docs/extensions/reference/api/runtime#event-onInstalled} + */ +chrome.runtime.onInstalled.addListener(async (details) => { + await updateContextMenus(); + + await Analytics.fireEvent('extension_lifecycle', { + reason: details.reason + }); +}); + +/** + * Fired when an action icon is clicked. This event will not fire if the action has a popup. + * + * @param {object} tab - The details of the tab where the action button was clicked. + * + * @see {@link https://developer.chrome.com/docs/extensions/reference/api/action#event-onClicked} + * @see {@link https://developer.chrome.com/docs/extensions/reference/api/tabs#type-Tab} + * @see {@link https://developer.chrome.com/docs/extensions/develop/ui/implement-action} + */ +chrome.action.onClicked.addListener(async (tab) => { + await newWindowWithCurrentAndTabsToRight(tab); + + await Analytics.fireEvent('task_triggered', { + source: 'action', + task: 'actionIcon' + }); +}); + +/** + * Fired when a context menu item is clicked. + * + * @param {object} info - Information about the item clicked and the context where the click happened. + * @param {object} tab - The details of the tab where the click happened. + * + * @see {@link https://developer.chrome.com/docs/extensions/reference/api/contextMenus#event-onClicked} + * @see {@link https://developer.chrome.com/docs/extensions/reference/api/contextMenus#type-OnClickData} + * @see {@link https://developer.chrome.com/docs/extensions/reference/api/tabs#type-Tab} + */ +chrome.contextMenus.onClicked.addListener(async (info, tab) => { + const handlers = { + newWindowWithCurrentAndTabsToRight, + newWindowWithTabsToRight, + aboutTheDeveloper, + togglePageContextMenu, + }; + + await handlers[info.menuItemId]?.(tab); + + await Analytics.fireEvent('task_triggered', { + source: 'contextMenu', + task: info.menuItemId + }); +}); + +/** + * Fired when a registered command is activated using a keyboard shortcut. + * + * @param {string} command - The name of the command. + * @param {object} tab - The details of the tab where the command was executed. + * + * @see {@link https://developer.chrome.com/docs/extensions/reference/api/commands#event-onCommand} + * @see {@link https://developer.chrome.com/docs/extensions/reference/api/tabs#type-Tab} + */ +chrome.commands.onCommand.addListener(async (command, tab) => { + const handlers = { + newWindowWithCurrentAndTabsToRight, + newWindowWithTabsToRight, + }; + + await handlers[command]?.(tab); + + await Analytics.fireEvent('task_triggered', { + source: 'command', + task: command + }); +}); + +/** + * Fired when one or more storage items change. + * + * Handles changes to the extension's settings/etc. + * + * @see {@link https://developer.chrome.com/docs/extensions/reference/storage#event-onChanged} + * @see {@link https://developer.chrome.com/docs/extensions/reference/api/storage#type-StorageChange} + */ +chrome.storage.onChanged.addListener(async (changes, areaName) => { + if (areaName === 'sync' && STORAGE_KEYS.SETTINGS in changes) { + const { oldValue = {}, newValue = {} } = changes[STORAGE_KEYS.SETTINGS]; + + // Check if any context menu-related settings have changed + const contextMenuSettingsChanged = CONTEXT_MENU_SETTINGS_KEYS.some((key) => { + return oldValue[key] !== newValue[key]; + }); + + if (contextMenuSettingsChanged) { + await updateContextMenus(); + } + } +}); + +/** + * Updates the context menus based on the current settings. + * + * @see {@link https://developer.chrome.com/docs/extensions/reference/api/contextMenus#method-removeAll} + * @see {@link https://developer.chrome.com/docs/extensions/reference/api/contextMenus#method-create} + * @see {@link https://developer.chrome.com/docs/extensions/develop/ui/context-menu} + */ +async function updateContextMenus() { + // Get settings from storage + const settings = await getSettings(); + + // Determine the contexts based on settings + const menuContexts = ["action"]; + if (settings.showInPageContext) { + menuContexts.push("page"); + } + + // Remove all existing context menus + await chrome.contextMenus.removeAll(); + + // Create context menus + + const menuRoot = chrome.contextMenus.create({ + contexts: menuContexts, + id: 'rootContextMenu', + title: "New window with.." + }); + + chrome.contextMenus.create({ + contexts: menuContexts, + parentId: menuRoot, + id: newWindowWithCurrentAndTabsToRight.name, + title: "..this tab and tabs to right" + }); + + chrome.contextMenus.create({ + contexts: menuContexts, + parentId: menuRoot, + id: newWindowWithTabsToRight.name, + title: "..tabs to right" + }); + + chrome.contextMenus.create({ + contexts: menuContexts, + parentId: menuRoot, + id: 'contextMenu-separator-1', + type: "separator" + }); + + // Create 'Options' submenu + + const optionsMenu = chrome.contextMenus.create({ + contexts: menuContexts, + parentId: menuRoot, + id: 'optionsMenu', + title: 'Options' + }); + + chrome.contextMenus.create({ + contexts: menuContexts, + parentId: optionsMenu, + id: togglePageContextMenu.name, + title: 'Show page context menu', + type: 'checkbox', + checked: settings.showInPageContext, + }); + + chrome.contextMenus.create({ + contexts: menuContexts, + parentId: menuRoot, + id: 'contextMenu-separator-2', + type: "separator" + }); + + chrome.contextMenus.create({ + contexts: menuContexts, + parentId: menuRoot, + id: aboutTheDeveloper.name, + title: "About the Developer" + }); +} + +/** + * Opens a new window with the current tab and tabs to the right. + * + * @param {object} tab - The details of the current tab. + * + * @see {@link https://developer.chrome.com/docs/extensions/reference/api/tabs#type-Tab} + */ +async function newWindowWithCurrentAndTabsToRight(tab) { + const tabIds = await getTabIdsToMove({ tab, includeCurrentTab: true }); + await createWindowWithTabs(tabIds); +} + +/** + * Opens a new window with tabs to the right of the current tab. + * + * @param {object} tab - The details of the current tab. + * + * @see {@link https://developer.chrome.com/docs/extensions/reference/api/tabs#type-Tab} + */ +async function newWindowWithTabsToRight(tab) { + const tabIds = await getTabIdsToMove({ tab, includeCurrentTab: false }); + await createWindowWithTabs(tabIds); +} + +/** + * Opens a new tab with information about the developer. + * + * @see {@link https://developer.chrome.com/docs/extensions/reference/api/tabs#method-create} + */ +async function aboutTheDeveloper() { + await chrome.tabs.create({ url: ABOUT_THE_DEVELOPER_URL, active: true }); +} + +/** + * Toggles the option to show the 'page' context menu on or off. + */ +async function togglePageContextMenu() { + await setSettings(settings => ({ + ...settings, + showInPageContext: !settings.showInPageContext, + })); +} + +/** + * Gets the IDs of tabs to move based on the current tab and whether to include the current tab. + * + * @param {object} options - Options for getting tab IDs. + * @param {object} options.tab - The details of the current tab. + * @param {boolean} options.includeCurrentTab - Whether to include the current tab. + * @returns {Promise} A promise that resolves to an array of tab IDs. + * + * @see {@link https://developer.chrome.com/docs/extensions/reference/api/tabs#method-query} + * @see {@link https://developer.chrome.com/docs/extensions/reference/api/tabs#type-Tab} + */ +async function getTabIdsToMove({ tab, includeCurrentTab }) { + let currentTab = tab; + + if (!currentTab) { + const [activeTab] = await chrome.tabs.query({ currentWindow: true, active: true }); + currentTab = activeTab; + } + + if (!currentTab || typeof currentTab.index === 'undefined' || typeof currentTab.windowId === 'undefined') return []; + + const tabs = await chrome.tabs.query({ windowId: currentTab.windowId }); + if (!tabs || tabs.length === 0) return []; + + const startIndex = includeCurrentTab ? currentTab.index : currentTab.index + 1; + return tabs.filter(t => t.index >= startIndex).map(t => t.id); +} + +/** + * Creates a new window with the specified tabs. + * + * @param {number[]} tabIds - The IDs of the tabs to move to the new window. + * + * @see {@link https://developer.chrome.com/docs/extensions/reference/api/windows#method-create} + * @see {@link https://developer.chrome.com/docs/extensions/reference/api/tabs#method-move} + */ +async function createWindowWithTabs(tabIds) { + if (!tabIds || tabIds.length === 0) return; + + const newWindow = await chrome.windows.create({ tabId: tabIds[0] }); + if (tabIds.length > 1) { + await chrome.tabs.move(tabIds.slice(1), { windowId: newWindow.id, index: -1 }); + } +} diff --git a/src/settings.js b/src/settings.js new file mode 100644 index 0000000..ab48041 --- /dev/null +++ b/src/settings.js @@ -0,0 +1,36 @@ +export const STORAGE_KEYS = { + SETTINGS: 'settings', +}; + +export const DEFAULT_SETTINGS = { + showInPageContext: true, +}; + +export const CONTEXT_MENU_SETTINGS_KEYS = ['showInPageContext']; + +/** + * Retrieves the extension settings from storage, applying defaults if necessary. + * + * @returns {Promise} A promise that resolves to the settings object. + */ +export async function getSettings() { + const result = await chrome.storage.sync.get(STORAGE_KEYS.SETTINGS); + return { + ...DEFAULT_SETTINGS, + ...result[STORAGE_KEYS.SETTINGS], + }; +} + +/** + * Updates the extension settings in storage using an updater function. + * + * Note: Setting changes are reacted to in the storage change listener + * + * @param {function} updater - A function that receives the current settings and returns the new settings. + * @returns {Promise} + */ +export async function setSettings(updater) { + const currentSettings = await getSettings(); + const newSettings = updater(currentSettings); + await chrome.storage.sync.set({ [STORAGE_KEYS.SETTINGS]: newSettings }); +}