Last active
April 20, 2026 14:17
-
-
Save ntcho/69f66df3b4864de38bea5b512ede74fd to your computer and use it in GitHub Desktop.
Google Account Switcher Userscript
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Google Account Switcher | |
| // @namespace http://tampermonkey.net/ | |
| // @version 1.1.0 | |
| // @updateURL https://gist.githubusercontent.com/ntcho/69f66df3b4864de38bea5b512ede74fd/raw/google-account-switcher.userscript.js | |
| // @downloadURL https://gist.githubusercontent.com/ntcho/69f66df3b4864de38bea5b512ede74fd/raw/google-account-switcher.userscript.js | |
| // @description Switch Google accounts using Option + ` and Option + 1-9 on macOS. | |
| // @author ntcho | |
| // @match *://*.google.com/* | |
| // @grant none | |
| // @run-at document-start | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // Easy configuration map: Map the physical key to the target account index | |
| const shortcutMap = { | |
| '`': 0, // Default account | |
| '1': 1, | |
| '2': 2, | |
| '3': 3, | |
| '4': 4, | |
| '5': 5, | |
| '6': 6, | |
| '7': 7, | |
| '8': 8, | |
| '9': 9 | |
| }; | |
| // Cooldown to prevent accidental rapid re-triggers | |
| let lastSwitchTime = 0; | |
| const DEBOUNCE_MS = 1000; | |
| /** | |
| * Translates the user-friendly shortcutMap into KeyboardEvent.code values. | |
| * This is necessary on macOS because Option + Key generates special characters, | |
| * making event.key unreliable for shortcut detection. | |
| */ | |
| const codeMap = {}; | |
| for (const [key, index] of Object.entries(shortcutMap)) { | |
| if (key === '`') { | |
| codeMap['Backquote'] = index; | |
| } else if (/^[0-9]$/.test(key)) { | |
| codeMap['Digit' + key] = index; | |
| } else if (/^[a-zA-Z]$/.test(key)) { | |
| codeMap['Key' + key.toUpperCase()] = index; | |
| } else { | |
| // Fallback for custom keys (user must provide exact event.code) | |
| codeMap[key] = index; | |
| } | |
| } | |
| /** | |
| * Checks if the current URL is an account switcher or sign-in flow. | |
| * @param {string} urlStr | |
| * @returns {boolean} | |
| */ | |
| function isAccountSwitcherUrl(urlStr) { | |
| const url = new URL(urlStr); | |
| const path = url.pathname.toLowerCase(); | |
| if (url.hostname === 'accounts.google.com') { | |
| return true; | |
| } | |
| const switcherPatterns = [ | |
| '/accounts/', | |
| '/accountchooser', | |
| '/signin', | |
| '/logout', | |
| '/servicelogin', | |
| '/listaccounts', | |
| ]; | |
| return switcherPatterns.some((pattern) => path.includes(pattern)); | |
| } | |
| /** | |
| * Determines the optimal URL for the target account index based on the service. | |
| * @param {string} currentUrlStr | |
| * @param {number} targetIndex (0-based) | |
| * @returns {string|null} The new URL, or null if the current URL already matches the target. | |
| */ | |
| function getTargetUrl(currentUrlStr, targetIndex) { | |
| const url = new URL(currentUrlStr); | |
| let path = url.pathname; | |
| // Check if the URL already explicitly targets the requested account index | |
| const currentUPathMatch = path.match(/\/u\/(\d+)/); | |
| const currentAuthUser = url.searchParams.get('authuser'); | |
| const isTargetUPath = | |
| currentUPathMatch && parseInt(currentUPathMatch[1], 10) === targetIndex; | |
| const isTargetAuthUser = currentAuthUser === targetIndex.toString(); | |
| // Prevent reloading if the url is already on the target user index | |
| if (isTargetUPath || isTargetAuthUser) { | |
| return null; | |
| } | |
| // Strategy 1: If the URL already contains a /u/{N} segment, replace it. | |
| if (currentUPathMatch) { | |
| url.pathname = path.replace(/\/u\/\d+/, `/u/${targetIndex}`); | |
| return url.toString(); | |
| } | |
| // Strategy 2: For known services that rely on /u/{N} but might not have it in the current path. | |
| const uPathDomains = [ | |
| 'mail.google.com', | |
| 'drive.google.com', | |
| 'docs.google.com', | |
| 'calendar.google.com', | |
| 'keep.google.com', | |
| 'photos.google.com', | |
| 'contacts.google.com', | |
| 'meet.google.com', | |
| 'chat.google.com', | |
| 'myaccount.google.com', | |
| ]; | |
| if (uPathDomains.includes(url.hostname)) { | |
| // Explicit support for Google Docs /d/ URLs | |
| if (url.hostname === 'docs.google.com' && path.includes('/d/')) { | |
| url.pathname = path.replace('/d/', `/u/${targetIndex}/d/`); | |
| url.pathname = url.pathname.replace(/\/{2,}/g, '/'); | |
| return url.toString(); | |
| } | |
| const segments = path.split('/').filter(Boolean); | |
| if (url.hostname === 'myaccount.google.com' || segments.length === 0) { | |
| // E.g., myaccount.google.com/ -> myaccount.google.com/u/1/ | |
| url.pathname = `/u/${targetIndex}${path.startsWith('/') ? path : '/' + path}`; | |
| } else { | |
| // E.g., mail.google.com/mail/ -> mail.google.com/mail/u/1/ | |
| const firstSegment = segments[0]; | |
| const restOfPath = segments.slice(1).join('/'); | |
| url.pathname = `/${firstSegment}/u/${targetIndex}/${restOfPath}`; | |
| } | |
| // Clean up double slashes just in case | |
| url.pathname = url.pathname.replace(/\/{2,}/g, '/'); | |
| return url.toString(); | |
| } | |
| // Strategy 3: Fallback to setting the authuser query parameter. | |
| url.searchParams.set('authuser', targetIndex.toString()); | |
| return url.toString(); | |
| } | |
| /** | |
| * Handles keyboard events to intercept Option + Mapped Key. | |
| */ | |
| function handleKeydown(event) { | |
| // Only trigger on Option (Alt) without other modifiers | |
| if (!event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { | |
| return; | |
| } | |
| // Check if the pressed key code exists in our translated map | |
| const targetIndex = codeMap[event.code]; | |
| if (targetIndex === undefined) { | |
| return; | |
| } | |
| // Prevent switching if the user is typing in an input field | |
| const activeEl = document.activeElement; | |
| if ( | |
| activeEl && | |
| (activeEl.tagName === 'INPUT' || | |
| activeEl.tagName === 'TEXTAREA' || | |
| activeEl.isContentEditable) | |
| ) { | |
| return; | |
| } | |
| const now = Date.now(); | |
| if (now - lastSwitchTime < DEBOUNCE_MS) { | |
| return; // Ignore rapid consecutive presses | |
| } | |
| const targetUrl = getTargetUrl(location.href, targetIndex); | |
| if (targetUrl && targetUrl !== location.href) { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| lastSwitchTime = now; | |
| // Use replace to avoid polluting the history stack | |
| location.replace(targetUrl); | |
| } | |
| } | |
| // Attach the event listener | |
| // Use capture phase to ensure it runs before other scripts stop propagation | |
| window.addEventListener('keydown', handleKeydown, true); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment