Switch User Agent

Spoof navigator user-agent values for configured sites; enables lobste.rs by default only on Brave and exposes console helpers.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Switch User Agent
// @namespace    https://github.com/o-az/userscripts
// @version      1.2
// @description  Spoof navigator user-agent values for configured sites; enables lobste.rs by default only on Brave and exposes console helpers.
// @author       https://github.com/o-az
// @match        *://*/*
// @homepageURL  https://github.com/o-az/userscripts
// @source       https://github.com/o-az/userscripts/blob/main/src/switch-user-agent.user.js
// @supportURL   https://github.com/o-az/userscripts/issues
// @tag          user-agent
// @tag          brave
// @tag          navigator
// @tag          compatibility
// @license      MIT
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        unsafeWindow
// @run-at       document-start
// @noframes
// ==/UserScript==

;(() => {
  'use strict'

  const STORAGE_KEY = 'switch-user-agent.rules.v1'
  const GLOBAL = /** @type {typeof globalThis & {
    unsafeWindow?: Window
    GM_getValue?: (key: string, defaultValue: string) => string
    GM_setValue?: (key: string, value: string) => void
  }} */ (globalThis)
  const PAGE = GLOBAL.unsafeWindow || window
  const CHROME_120_WINDOWS_UA =
    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'

  const UA_PRESETS = [
    {
      id: 'chrome-windows',
      label: 'Chrome on Windows',
      keywords: [
        'chrome',
        'chromium',
        'brave',
        'google',
        'windows',
        'win',
        'desktop',
        'default',
      ],
      userAgent: CHROME_120_WINDOWS_UA,
      platform: 'Win32',
      vendor: 'Google Inc.',
      uaPlatform: 'Windows',
      brands: [
        { brand: 'Not A(Brand', version: '99' },
        { brand: 'Google Chrome', version: '120' },
        { brand: 'Chromium', version: '120' },
      ],
    },
    {
      id: 'chrome-mac',
      label: 'Chrome on macOS',
      keywords: [
        'chrome',
        'chromium',
        'brave',
        'google',
        'mac',
        'macos',
        'osx',
        'desktop',
      ],
      userAgent:
        'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
      platform: 'MacIntel',
      vendor: 'Google Inc.',
      uaPlatform: 'macOS',
      brands: [
        { brand: 'Not A(Brand', version: '99' },
        { brand: 'Google Chrome', version: '120' },
        { brand: 'Chromium', version: '120' },
      ],
    },
    {
      id: 'edge-windows',
      label: 'Edge on Windows',
      keywords: ['edge', 'edg', 'microsoft', 'windows', 'win', 'desktop'],
      userAgent:
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0',
      platform: 'Win32',
      vendor: 'Google Inc.',
      uaPlatform: 'Windows',
      brands: [
        { brand: 'Not A(Brand', version: '99' },
        { brand: 'Microsoft Edge', version: '120' },
        { brand: 'Chromium', version: '120' },
      ],
    },
    {
      id: 'safari-mac',
      label: 'Safari on macOS',
      keywords: ['safari', 'mac', 'macos', 'osx', 'desktop'],
      userAgent:
        'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15',
      platform: 'MacIntel',
      vendor: 'Apple Computer, Inc.',
      uaPlatform: 'macOS',
      brands: [],
    },
    {
      id: 'firefox-windows',
      label: 'Firefox on Windows',
      keywords: ['firefox', 'ff', 'mozilla', 'windows', 'win', 'desktop'],
      userAgent:
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0',
      platform: 'Win32',
      vendor: '',
      uaPlatform: 'Windows',
      brands: [],
    },
    {
      id: 'iphone-safari',
      label: 'Safari on iPhone',
      keywords: ['iphone', 'ios', 'mobile', 'safari', 'phone'],
      userAgent:
        'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1',
      platform: 'iPhone',
      vendor: 'Apple Computer, Inc.',
      uaPlatform: 'iOS',
      mobile: true,
      brands: [],
    },
  ]

  const DEFAULT_PRESET_ID = 'chrome-windows'

  const isBraveBrowser = () => {
    const navigatorWithBrave =
      /** @type {Navigator & { brave?: { isBrave?: () => Promise<boolean> } }} */ (
        PAGE.navigator
      )
    return typeof navigatorWithBrave.brave?.isBrave === 'function'
  }

  /** @param {string | undefined} presetId */
  const getPresetById = (presetId) =>
    UA_PRESETS.find((preset) => preset.id === presetId) ||
    /** @type {(typeof UA_PRESETS)[number]} */ (UA_PRESETS[0])

  /**
   * Pick the most likely preset from one or two human keywords like:
   * 'chrome', 'windows chrome', 'safari mac', 'iphone', or 'firefox'.
   *
   * @param {string} [keywords]
   */
  const pickPreset = (keywords = DEFAULT_PRESET_ID) => {
    const searchTerms = keywords
      .toLowerCase()
      .split(/[\s,;+/-]+/)
      .filter(Boolean)

    if (searchTerms.length === 0) return getPresetById(DEFAULT_PRESET_ID)

    const exactPreset = UA_PRESETS.find(
      (preset) => preset.id === keywords.toLowerCase(),
    )
    if (exactPreset) return exactPreset

    return UA_PRESETS.map((preset) => ({
      preset,
      score: searchTerms.reduce((score, term) => {
        if (preset.id.includes(term)) return score + 4
        if (preset.keywords.includes(term)) return score + 3
        if (preset.label.toLowerCase().includes(term)) return score + 2
        return score
      }, 0),
    })).sort((a, b) => b.score - a.score)[0]?.preset
  }

  /** @param {string | string[]} hostnames */
  const normalizeHostnames = (hostnames) => {
    const values = Array.isArray(hostnames) ? hostnames : [hostnames]
    return [
      ...new Set(
        values.flatMap((value) => {
          const hostname = value
            .trim()
            .replace(/^https?:\/\//, '')
            .replace(/\/.*$/, '')
            .toLowerCase()

          if (!hostname || hostname.startsWith('*.')) return [hostname]
          if (hostname.startsWith('www.')) return [hostname, hostname.slice(4)]
          return [hostname, `www.${hostname}`]
        }),
      ),
    ].filter(Boolean)
  }

  /**
   * @param {string | string[]} hostnames
   * @param {string} [keywords]
   * @param {Record<string, unknown>} [overrides]
   */
  const createRule = (
    hostnames,
    keywords = DEFAULT_PRESET_ID,
    overrides = {},
  ) => {
    const preset = pickPreset(keywords) || getPresetById(DEFAULT_PRESET_ID)
    const normalizedHostnames = normalizeHostnames(hostnames)
    const {
      name,
      hostnames: _hostnames,
      hostname: _hostname,
      ...rest
    } = overrides

    return {
      preset: preset.id,
      userAgent: preset.userAgent,
      platform: preset.platform,
      vendor: preset.vendor,
      uaPlatform: preset.uaPlatform,
      mobile: preset.mobile || false,
      brands: preset.brands,
      ...rest,
      name:
        typeof name === 'string'
          ? name
          : normalizedHostnames[0] || 'Custom site',
      hostnames: normalizedHostnames,
    }
  }

  /**
   * Built-in rules live in the userscript so they work before any page scripts run.
   *
   * Add more sites here when you want the rule to be synced with the userscript:
   * createRule('example.com', 'chrome')
   */
  const DEFAULT_RULES = isBraveBrowser()
    ? [createRule('lobste.rs', 'chrome windows', { name: 'Lobsters' })]
    : []

  const getStoredRules = () => {
    try {
      const raw = GLOBAL.GM_getValue
        ? GLOBAL.GM_getValue(STORAGE_KEY, '[]')
        : localStorage.getItem(STORAGE_KEY) || '[]'
      const parsed = JSON.parse(raw)
      return Array.isArray(parsed) ? parsed : []
    } catch {
      return []
    }
  }

  /** @param {Array<Record<string, unknown>>} rules */
  const setStoredRules = (rules) => {
    const serializedRules = JSON.stringify(rules, null, 2)

    if (GLOBAL.GM_setValue) {
      GLOBAL.GM_setValue(STORAGE_KEY, serializedRules)
      return
    }

    localStorage.setItem(STORAGE_KEY, serializedRules)
  }

  const getRules = () => [...getStoredRules(), ...DEFAULT_RULES]

  /** @param {string} pattern */
  const hostnameMatches = (pattern) => {
    const hostname = location.hostname.toLowerCase()
    const normalizedPattern = pattern.toLowerCase()

    if (normalizedPattern.startsWith('*.')) {
      const suffix = normalizedPattern.slice(2)
      return hostname === suffix || hostname.endsWith(`.${suffix}`)
    }

    return hostname === normalizedPattern
  }

  /** @param {Record<string, unknown>} rule */
  const ruleMatches = (rule) => {
    const hostnames = Array.isArray(rule.hostnames)
      ? rule.hostnames
      : rule.hostname
        ? [rule.hostname]
        : []

    const hostMatch = hostnames.some(
      (hostname) => typeof hostname === 'string' && hostnameMatches(hostname),
    )

    if (!hostMatch) return false

    if (typeof rule.pathnamePrefix === 'string') {
      return location.pathname.startsWith(rule.pathnamePrefix)
    }

    if (typeof rule.hrefPattern === 'string') {
      try {
        return new RegExp(rule.hrefPattern).test(location.href)
      } catch {
        return false
      }
    }

    return true
  }

  /** @param {Record<string, unknown>} rule */
  const getUserAgentData = (rule) => {
    const brands = Array.isArray(rule.brands) ? rule.brands : []

    if (brands.length === 0) return undefined

    return {
      brands,
      mobile: rule.mobile === true,
      platform:
        typeof rule.uaPlatform === 'string' ? rule.uaPlatform : 'Windows',
      getHighEntropyValues: async (/** @type {string[]} */ hints) => {
        const values = {
          architecture: 'x86',
          bitness: '64',
          brands,
          fullVersionList: brands.map((brand) => ({
            ...brand,
            version: `${brand.version}.0.0.0`,
          })),
          mobile: rule.mobile === true,
          model: '',
          platform:
            typeof rule.uaPlatform === 'string' ? rule.uaPlatform : 'Windows',
          platformVersion: '10.0.0',
          uaFullVersion: '120.0.0.0',
          wow64: false,
        }

        const typedValues = /** @type {Record<string, unknown>} */ (values)

        return Object.fromEntries(
          hints
            .filter((hint) => Object.hasOwn(typedValues, hint))
            .map((hint) => [hint, typedValues[hint]]),
        )
      },
      toJSON() {
        return {
          brands: this.brands,
          mobile: this.mobile,
          platform: this.platform,
        }
      },
    }
  }

  /**
   * @param {object} target
   * @param {string} property
   * @param {unknown} value
   */
  const defineNavigatorGetter = (target, property, value) => {
    try {
      Object.defineProperty(target, property, {
        configurable: true,
        enumerable: true,
        get: () => value,
      })
    } catch {
      // Some browsers expose non-configurable navigator properties. Try the next target.
    }
  }

  /** @param {Record<string, unknown>} rule */
  const applyRule = (rule) => {
    if (typeof rule.userAgent !== 'string') return

    const navigatorPrototype = Object.getPrototypeOf(PAGE.navigator)
    const properties = {
      userAgent: rule.userAgent,
      appVersion: rule.userAgent.replace(/^Mozilla\//, ''),
      platform: typeof rule.platform === 'string' ? rule.platform : 'Win32',
      vendor: typeof rule.vendor === 'string' ? rule.vendor : 'Google Inc.',
      userAgentData: getUserAgentData(rule),
    }

    for (const [property, value] of Object.entries(properties)) {
      defineNavigatorGetter(navigatorPrototype, property, value)
      defineNavigatorGetter(PAGE.navigator, property, value)
    }

    console.info(
      '[Switch User Agent] Active rule:',
      rule.name || rule.hostnames,
    )
    console.info(
      '[Switch User Agent] navigator.userAgent:',
      PAGE.navigator.userAgent,
    )
  }

  const activeRule = getRules().find(ruleMatches)
  if (activeRule) applyRule(activeRule)

  const api = {
    currentRule: activeRule || null,
    listRules: getRules,
    listCustomRules: getStoredRules,
    saveCustomRules: setStoredRules,
    presets: () =>
      UA_PRESETS.map(({ id, label, keywords, userAgent }) => ({
        id,
        label,
        keywords,
        userAgent,
      })),
    pickPreset,
    createRule,
    /**
     * Add a site with one or two human keywords. Defaults to Chrome on Windows.
     *
     * Examples:
     * SwitchUserAgent.add('example.com')
     * SwitchUserAgent.add('example.com', 'safari mac')
     * SwitchUserAgent.add(['example.com', '*.example.com'], 'firefox')
     *
     * @param {string | string[]} hostnames
     * @param {string} [keywords]
     * @param {Record<string, unknown>} [overrides]
     */
    add(hostnames, keywords = DEFAULT_PRESET_ID, overrides = {}) {
      const customRules = getStoredRules()
      const nextRule = createRule(hostnames, keywords, overrides)
      customRules.push(nextRule)
      setStoredRules(customRules)
      return nextRule
    },
    /** @param {Record<string, unknown>} rule */
    addRule(rule) {
      const customRules = getStoredRules()
      const hostnames = Array.isArray(rule.hostnames)
        ? rule.hostnames.filter((hostname) => typeof hostname === 'string')
        : typeof rule.hostname === 'string'
          ? rule.hostname
          : []
      const nextRule = rule.userAgent ? rule : createRule(hostnames)
      customRules.push(nextRule)
      setStoredRules(customRules)
      return nextRule
    },
    /** @param {string} nameOrHostname */
    removeRule(nameOrHostname) {
      const customRules = getStoredRules().filter((rule) => {
        const hostnames = Array.isArray(rule.hostnames)
          ? rule.hostnames
          : rule.hostname
            ? [rule.hostname]
            : []

        return (
          rule.name !== nameOrHostname && !hostnames.includes(nameOrHostname)
        )
      })
      setStoredRules(customRules)
      return customRules
    },
    exampleRule: createRule('example.com', 'chrome'),
  }

  Object.defineProperty(PAGE, 'SwitchUserAgent', {
    configurable: true,
    value: api,
  })
})()