Building a Trading Bot Control Panel as a Chrome Extension (Manifest V3)

🌐 Korean

Once the backend lives on Google Apps Script, the next question arrives: what do I drive it with? GAS can serve HTML from doGet, so a web app screen is the obvious answer, and that is where this started. A few weeks in, one friction had piled up: checking the bot meant opening a tab, finding the URL, and waiting for a load. Twenty times a session.

So the control surface moved into a Chrome extension popup. This post is that migration, and what Manifest V3 had to say about it.

Why an extension instead of a web dashboard

One reason: it is always one click away.

  • No tab to open — just a toolbar icon
  • No URL to remember — the deployment URL hides inside the extension
  • It appears over whatever page you are on — glance at a chart, act immediately
  • If the browser is open, it is in the same place it always was

What you give up is equally clear: the screen is small. A popup is effectively about 400px wide. That decides the character of the thing — this is a remote control, not a dashboard. No charts, no analytics; just current state, start/stop, and parameter edits. The narrow screen ended up choosing the feature set, which was a favor.

Dark theme extension popup showing the bot status pill, action buttons and ticker cards

The full popup. Status pill, action bar, ticker cards and account settings stacked vertically in 400px.

Manifest V3: fewer permissions is the skill

An MV3 manifest is shorter than people expect. What matters is not what you put in, but what you leave out.

{

  "manifest_version": 3,

  "name": "Stock Trading Bot",

  "description": "Control panel for an Apps Script based stock trading bot",

  "version": "3.1",

  "action": {

    "default_icon": {

      "16": "icons/icon16.png",

      "48": "icons/icon48.png",

      "128": "icons/icon128.png"

    },

    "default_popup": "popup.html",

    "default_title": "Stock Trading Bot"

  },

  "permissions": [

    "windows"

  ],

  "host_permissions": [

    "https://script.google.com/*"

  ],

  "icons": {

    "16": "icons/icon16.png",

    "48": "icons/icon48.png",

    "128": "icons/icon128.png"

  }

}

Three things matter here.

permissions has exactly one entry. windows, used only to open an external link in a new window. No storage, no tabs, no activeTab. Inside a popup localStorage just works, so the storage permission is unnecessary — a lot of extensions request it out of habit.

host_permissions names one domain. Not <all_urls>, just https://script.google.com/*. The extension can reach exactly one place — its own backend — and the install-time warning shrinks accordingly.

There are no content_scripts. This extension injects nothing into anyone's pages; it lives entirely inside its popup. That single fact carries the most weight in review — more on that in the next post.

One wrapper for the backend

The code that talks to the backend is one file with one function.

// api.js — the deployment URL exists in exactly one place

const WEBAPP_URL = "https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec";

async function callAPI(action, data = {}) {

  const response = await fetch(WEBAPP_URL, {

    method: "POST",

    mode: "cors",

    headers: { "Content-Type": "application/json" },

    body: JSON.stringify({

      action,

      payload: data

    })

  });

  return response.json();

}

That is the whole client. Because the backend accepts one POST with an action field (see the previous post), the frontend gets to be equally thin. Call sites read like this:

const params = await callAPI('getParameters');

await callAPI('saveParameters', formData);

await callAPI('startBot');

await callAPI('stopBot');

The practical payoff of a single wrapper is that the deployment URL lives on one line. GAS can hand you a new URL on redeploy, and when it does there is exactly one place to edit.

One caution: GAS web apps do not tolerate preflight well. Add a custom header or an authorization header and the browser sends an OPTIONS request first, which tends not to survive. That is why the code above sends only Content-Type: application/json. If you need authentication, carrying a token in the body rather than a header causes far less friction.

The theme must not flicker

The classic bug when adding a dark/light toggle is a white flash on open. Applying the theme in DOMContentLoaded is already too late — a frame gets painted with default styles first. A popup re-renders every time it opens, so you see the flash every single time.

The fix is small: run an IIFE that stamps the attribute at parse time.

// top of popup.js — do not wait for DOMContentLoaded

(function () {

  const saved = localStorage.getItem('onstock-theme') || 'dark';

  document.documentElement.setAttribute('data-theme', saved);

})();

document.addEventListener('DOMContentLoaded', () => {

  const themeBtn = document.getElementById('themeToggle');

  if (themeBtn) {

    themeBtn.addEventListener('click', () => {

      const cur  = document.documentElement.getAttribute('data-theme');

      const next = cur === 'dark' ? 'light' : 'dark';

      document.documentElement.setAttribute('data-theme', next);

      localStorage.setItem('onstock-theme', next);

    });

  }

});

The CSS side only swaps variables off the data-theme attribute.

:root {

  --bg:   #0e1116;

  --card: #161b22;

  --text: #e6edf3;

}

:root[data-theme="light"] {

  --bg:   #f6f8fa;

  --card: #ffffff;

  --text: #1f2328;

}

body { background: var(--bg); color: var(--text); }

The choice persists in localStorage, which — as noted — works without the storage permission inside a popup. But that value lives only in this browser profile, so it resets on a different machine. For a preference like theme that is correct; for real state like account parameters, the backend has to own it. Blur that line and you get settings that differ per PC.

Light theme extension popup, same layout

The same screen in light theme. Only CSS variables change, so there is a single copy of the markup.

Keeping the status pill honest

A pill at the top shows whether the bot is running — green and pulsing for ON, red for OFF. The problem is that several code paths update that value: the initial popup load, the start/stop button, and the response after saving settings. If each of those pokes the pill directly, the moment you miss one, the screen lies.

So the direction got inverted. Nothing touches the pill directly. Code updates only the #RunBot element where the value is displayed, and the pill watches that text and follows it.

const pill   = document.getElementById('botPill');

const target = document.getElementById('RunBot');

if (pill && target) {

  const sync = () => {

    const on = (target.textContent || '').trim() === 'ON';

    pill.classList.toggle('on', on);

    pill.classList.toggle('off', !on);

  };

  sync();                       // once at startup

  new MutationObserver(sync)

    .observe(target, { childList: true, characterData: true, subtree: true });

}

A MutationObserver catches the text change and fixes the classes. Now it does not matter how many places change the state — as long as #RunBot is right, the pill is right. There is one place where the decision is made, and for a screen like this that beats repeating the logic.

The same idea applies to the start/stop control. Instead of two buttons there is one toggle, and pressing it re-asks the backend which direction to go.

btn.addEventListener('click', async () => {

  // Ask the backend again instead of trusting what is on screen.

  // Another device may already have stopped it.

  const params    = await callAPI('getParameters');

  const isRunning = (params.RunBot && params.RunBot !== 'OFF');

  const res = isRunning ? await callAPI('stopBot')

                        : await callAPI('startBot');

  log(isRunning ? 'stop requested' : 'start requested');

  await reload();

});

The key is not trusting the on-screen value at click time. A popup can sit open for minutes, and the state may have changed elsewhere. Skip this and "I pressed stop and it started" genuinely happens.

What MV3 broke: no inline scripts

The biggest snag when porting the web app screen was CSP. MV3 blocks inline <script> blocks and onclick="" attributes outright. The web app HTML was full of both, and after pasting it in, nothing worked — silently.

The fix is mechanical: strip every inline script into a single popup.js, and convert onclick attributes to addEventListener. Tedious, but it separates markup from behavior, and the code came out better for it.

One rule held throughout the port: not a single input name attribute changed. The backend maps form data to properties by name, so touching them would desynchronize server-side parsing from every stored value. The UI was rebuilt completely while the contract stayed frozen — which is why the backend needed no changes at all.

Password fields as basic manners

The account settings screen takes an API key and secret. Both are type="password".

<input type="password" name="APIKey"    autocomplete="off" />

<input type="password" name="APISecret" autocomplete="off" />

This is not encryption. It only stops screen shares and shoulder surfing. But a popup can open anywhere at any time, so this should be the default. The actual storage happens in per-user properties on the backend; the extension never holds the values.

Summary

  • An extension popup buys "always one click away" — and 400px picks your feature set for you
  • With MV3 permissions, leaving things out is the skilllocalStorage works in a popup without storage
  • Use one wrapper for backend calls so the deployment URL lives on a single line
  • Apply the theme at parse time or it flickers on every open
  • Do not update status indicators by hand — let a MutationObserver follow one source
  • A toggle should re-ask the backend on click; the on-screen value may be stale
  • Porting to MV3 means removing every inline script while freezing the name contract

The next post covers actually publishing this to the Chrome Web Store and getting through review.

Note: this is an engineering write-up, not investment advice. Automated trading can lose money through software bugs and network failures; trading decisions and their outcomes are your own responsibility.

댓글

이 블로그의 인기 게시물

한국투자증권 KIS API로 실시간 시세 받기 (WebSocket 실전)

파이썬으로 업비트 API 연동하기 — 시세 조회부터 주문까지 기초

Go로 자동매매 신호봇 프레임워크 설계하기