Skip to content

Instantly share code, notes, and snippets.

@ntcho
Last active April 20, 2026 14:17
Show Gist options
  • Select an option

  • Save ntcho/69f66df3b4864de38bea5b512ede74fd to your computer and use it in GitHub Desktop.

Select an option

Save ntcho/69f66df3b4864de38bea5b512ede74fd to your computer and use it in GitHub Desktop.
Google Account Switcher Userscript
// ==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