7
11 Comments

I built a Chrome extension where right-click menu items run real JavaScript

I'm the dev behind Menu Mod, a Chrome extension for building custom right-click menus. If you've ever wanted a right click to actually do something (hit an API, check a price, kick off a small automation) instead of just opening a search URL, that's what this is.

Scripting support just shipped in v2.

TLDR: Each menu item can run a full JS snippet in a sandboxed Web Worker, triggered by a single right click, with a 5 minute execution budget and a 30 MB cap on whatever the script returns. So instead of writing a whole extension for one small repetitive task, you write one script and bind it to a menu item.

Manifest v3 introduced a lot of 'interesting' constraints around user defined code that I have worked hard to address:

1. CORS limitations in the worker, and a proxy around it
The sandbox runs on a null origin, so a plain fetch() inside a script hits normal CORS rules and gets blocked by anything that doesn't explicitly allow null.

To fix this, I added proxyFetch(). proxyFetch routes the request through the extension's own origin instead, which sidesteps that. It requires the user to grant a host permission first through a URL pattern in settings, Chrome prompts for approval and any host that hasn't been explicitly permitted just fails with a clear error.

In practice, this means your script can talk to basically any internal tool or third party API you actually use instead of just APIs with permissive CORS.

2. No DOM but graphics manipulation still works
Workers don't get a DOM, so no document.createElement, no Image(), no HTML or XML parsing. What they do get is OffscreenCanvas for actual 2D rendering, path drawing and pixel manipulation, plus createImageBitmap for hardware accelerated image decoding. FileReaderSync is also available, so a script can read a Blob into base64.

That means things like resizing an image, drawing a watermark or generating a quick thumbnail on right click are doable without opening any other tool.

3. Post Script Actions
A script can return a plain string for a quick notification, or one or more command objects that run after the script finishes: open a URL, copy to clipboard, download a file, show a notification or chain several of those together.

Example, a script that pulls repo info on right click:

const repo = context.selection?.trim()

const data = await fetch(`https://api.github.com/repos/${repo}`).then(res => res.json())

return [
  {
    action: 'showNotification', 
    payload: { title: data.full_name, text: `${data.stargazers_count} stars, ${data.open_issues_count} open issues` }
  },
  {
    action: 'copyToClipboard', 
    payload: { text: data.clone_url }
  },
  {
    action: 'openUrl', 
    payload: { url: data.html_url }
  }
]

Highlight a repo name like react/react, right click, get a notification with stars and open issues, the clone URL copied to your clipboard and the repo page opened, all from one script tied to one menu item.

Are there any risks?
No DOM access and no direct chrome.* API access means a script can't reach into the page you right-clicked on or touch extension internals directly. It can still make network requests, import code from an allowlisted set of CDNs (jsDelivr, unpkg, esm.sh and a few others) and send data somewhere.
Basically, the same rule you'd apply to anything you paste into a browser console.

If there's a repetitive right-click-then-alt-tab thing you do every day, this is probably a five line script away from being one click/shortcut.

Quick demo - https://www.youtube.com/watch?v=GQtmLrb1fF4

Chrome Web Store - https://chromewebstore.google.com/detail/menu-mod-right-click-menu/hidbgnneihkhinffhjbkkdacpgmdlcgj

Still actively building this, so if you run into rough edges or have some ideas, I'm listening.

posted to Icon for group Share Your Project
Share Your Project
on August 15, 2026
  1. 1

    This sounds like one of those tools where the use cases only become obvious after people start using it. Being able to attach small JavaScript actions directly to the context menu could remove a lot of repetitive browser tasks. Do users create the scripts themselves, or are you planning some kind of shared library too?

  2. 1

    This sounds like one of those tools where the use cases only become obvious after people start using it. Being able to attach small JavaScript actions directly to the context menu could remove a lot of repetitive browser tasks. Do users create the scripts themselves, or are you planning some kind of shared library too?

  3. 1

    Nice approach, and the sandboxed-Worker choice is a smart way to stay MV3-compliant: user-authored scripts run from a local Blob instead of remote code, so you sidestep the "no remotely-hosted code" rule that kills a lot of extension ideas.

    The one thing I'd poke at: a Web Worker has no DOM access and can't see the current page's JS context. So "hit an API" works great (fetch is happy in a worker), but "check a price" - if that means reading the price off the page you're actually looking at - can't work from the worker alone. You'd need a content script to scrape the DOM and message-pass the value in. Might be worth being explicit about that split, because people will assume a right-click script can read the page it was clicked on, and the sandbox is exactly why it can't.

    Second thing, more caution than critique: the moment scripts become shareable, you inherit the userscript ecosystem's supply-chain problem - someone pastes a snippet from a forum that quietly fetch()es their session cookies out. Even inside the Worker sandbox, fetch plus whatever host permissions you've granted is enough to exfiltrate. If you ever add an import/share flow, a permission preview ("this script will talk to these domains") would go a long way.

    Cool project though - the 5-min budget and return cap show you've already thought about runaways.

  4. 1

    Being able to handle image tweaks or quick API lookups right from the context menu via OffscreenCanvas is brilliant. I can think of three annoying micro-tasks I do every day—like fetching quick stats or reformatting selected text—where I usually alt-tab to a terminal or web app. Replacing all that with a 5-line right-click script is huge.

  5. 1

    Nice work on the MV3 wrangling. One thing I'd want spelled out in the docs: where does that 5 minute worker actually live? The service worker gets torn down after 30 seconds idle, and Chrome only lets an extension keep one offscreen document open at a time, so if that's the host then two long scripts fired back to back are sharing it. Does the second one queue, get its own worker inside the same document, or clobber the first?

  6. 1

    The MV3 constraint work is actually the moat here — most developers who wanted this gave up when Manifest v3 closed the easy path. The fact that you've solved the CORS proxy, the null-origin worker isolation, and the storage cap means this isn't a weekend project that breaks in three months. That's worth communicating clearly in the positioning.

    The distribution question I'd be thinking about: the person who knows they want "a right-click that hits an API" is a developer who could build their own extension. The person who'd pay for this without knowing the technical framing is the ops or power user who just knows they do the same five things every day and wants a shortcut.

    Those two audiences need completely different onboarding — one wants the script editor open by default, the other wants a working script library they can deploy with one click without ever seeing code.

    Which of those two actually shows up first when someone installs it today?

  7. 1

    The five-minute worker budget is what makes this more than a fancy search menu. Before adding more actions, I'd build a dry-run view. It could show the selected text, hosts contacted, and commands that would run, without actually running anything. That would make copied scripts easier to inspect and debug, especially when a chain ends by copying to the clipboard, downloading something, or opening a URL. A one-click test run with sample context could also save you a pile of support tickets.

  8. 1

    The ability to turn a right-click into an actual script execution is a pretty interesting upgrade from the usual custom menu extensions. Curious what people end up using scripting for first.

  9. 1

    The per-host permission gate feels like the right primitive here. One thing I'd consider is making permissions visible per script, not just globally: a small capability summary (reads selected text, contacts api.github.com, copies to clipboard, opens a URL) before saving or running would make shared snippets much easier to trust. Are host grants currently scoped to the whole extension or to each menu item?

    1. 1

      Thanks for the feedback.

      Host grants are currently scoped to the whole extension, not per menu item.

      The per-script capability summary is a great suggestion. I have added it to the roadmap.

  10. 1

    A few more scripting samples

    1: CDN import

    const { default: dayjs } = await import('https://cdn.jsdelivr.net/npm/[email protected]/+esm')
    
    const formatted = dayjs().format('dddd, MMMM D, YYYY')
    
    return `Today is ${formatted}`
    

    2: One click image watermarker

    // Global Configuration & Constants
    const WATERMARK_URL = 'https://cats-nine-zeta.vercel.app/cat.png'
    const WATERMARK_SCALE_FACTOR = 0.1 // Target 10% of the host image's matching shortest side
    const CANVAS_PADDING_FACTOR = 0.01 // 1% margin responsive to each respective side's dimension
    
    const DEFAULT_MIME = 'application/octet-stream'
    
    // Directory support for organized downloads
    const SAVE_FILENAME = `MenuMod_Watermarks/watermarked_image-${Date.now()}.png`
    
    const base64ToBlob = (base64, mimeType) => {
      const binary = atob(base64)
      const bytes = new Uint8Array(binary.length)
      for (let i = 0; i < binary.length; i++) {
        bytes[i] = binary.charCodeAt(i)
      }
      return new Blob([bytes], { type: mimeType })
    }
    
    // Note: Grant the target host in Settings -> Host Permissions.
    try {
      // 1. Safely extract the dynamic target URL using optional chaining to prevent crashes if context is missing
      const targetUrl = context?.srcUrl || ''
    
      // 2. Guard clause: Ensure we actually have an image to work with before hitting the network
      if (!targetUrl || targetUrl.trim() === '') {
        throw new Error('No valid image URL found. Try right-clicking on an image.')
      }
    
      // 3. Fetch both assets through the extension's CORS-bypassing proxy concurrently
      let targetResult, watermarkResult
    
      try {
        ;[targetResult, watermarkResult] = await Promise.all([proxyFetch(targetUrl, { responseType: 'arraybuffer' }), proxyFetch(WATERMARK_URL, { responseType: 'arraybuffer' })])
      } catch (netErr) {
        throw new Error(`Network request failed. Ensure host access permissiom is granted in Settings -> Host Permissions.`)
      }
    
      if (!targetResult.ok) {
        throw new Error(`Failed to fetch target image (Proxy status: ${targetResult.status}${targetResult.error ? ` - ${targetResult.error}` : ''})`)
      }
    
      if (!watermarkResult.ok) {
        throw new Error(`Failed to fetch watermark image (Proxy status: ${watermarkResult.status}${watermarkResult.error ? ` - ${watermarkResult.error}` : ''})`)
      }
    
      // 4. Decode the Base64 bodies into Blobs concurrently (MIME pulled from response headers)
      const targetMime = targetResult.headers['content-type'] || DEFAULT_MIME
      const watermarkMime = watermarkResult.headers['content-type'] || DEFAULT_MIME
      const [targetBlob, watermarkBlob] = await Promise.all([base64ToBlob(targetResult.body, targetMime), base64ToBlob(watermarkResult.body, watermarkMime)])
    
      // 5. Decode binary data into hardware-accelerated ImageBitmaps in parallel
      const [targetImg, rawWatermarkImg] = await Promise.all([createImageBitmap(targetBlob), createImageBitmap(watermarkBlob)])
    
      // 6. Calculate proportional dimensions where the shortest host side scales the corresponding watermark side
      let watermarkWidth, watermarkHeight
      const aspectRatio = rawWatermarkImg.height / rawWatermarkImg.width // Dynamically handles any image scale
    
      if (targetImg.width <= targetImg.height) {
        watermarkWidth = targetImg.width * WATERMARK_SCALE_FACTOR
        watermarkHeight = watermarkWidth * aspectRatio
      } else {
        watermarkHeight = targetImg.height * WATERMARK_SCALE_FACTOR
        watermarkWidth = watermarkHeight / aspectRatio
      }
    
      // 7. Spin up an isolated OffscreenCanvas mapped exactly to the host image sizes
      const canvas = new OffscreenCanvas(targetImg.width, targetImg.height)
      const ctx = canvas.getContext('2d')
    
      // 8. Enforce high-quality resampling filters to prevent pixelation during scaling
      ctx.imageSmoothingEnabled = true
      ctx.imageSmoothingQuality = 'high'
    
      // 9. Composite the graphics: draw the base image, then calculate bottom-right coordinates
      ctx.drawImage(targetImg, 0, 0)
    
      const paddingX = targetImg.width * CANVAS_PADDING_FACTOR
      const paddingY = targetImg.height * CANVAS_PADDING_FACTOR
      const x = targetImg.width - watermarkWidth - paddingX
      const y = targetImg.height - watermarkHeight - paddingY
    
      // Scale and stamp the watermark asset directly onto the canvas context
      ctx.drawImage(rawWatermarkImg, x, y, watermarkWidth, watermarkHeight)
    
      // 10. Asynchronously encode the canvas pixel array into a standard PNG Blob
      const finalBlob = await canvas.convertToBlob({ type: 'image/png' })
    
      // 11. Synchronously convert the binary blob into a Base64 Data URL for message passing
      const reader = new FileReaderSync()
      const dataUrl = reader.readAsDataURL(finalBlob)
    
      // 12. Return the actionable download payload out of the worker context
      return {
        action: 'downloadFile',
        payload: {
          url: dataUrl,
          filename: SAVE_FILENAME
        }
      }
    } catch (error) {
      // Catch ALL errors—whether it's an undefined context, network failure, or canvas issue
      throw new Error(`Watermark & Download Script Error: ${error.message}`)
    }
    
Trending on Indie Hackers
I built a launch coach after my own product launch got 11 upvotes and 3 signups User Avatar 30 comments 4 months to go. Chrome extension live. Web search integrated. 4 users. $0 revenue. Still here. User Avatar 29 comments Solo → Pre-Seed: The Tool Stack Decision That Will Either Save or Sink Your First 18 Months User Avatar 26 comments Most directories forget you exist after you list. We're trying something different. User Avatar 22 comments Two-way is not the same as symmetric User Avatar 22 comments Built TermsGuard to explain contracts in plain English — looking for feedback User Avatar 12 comments