diff --git a/.github/workflows/chrome-extension.yml b/.github/workflows/chrome-extension.yml
new file mode 100644
index 00000000..f3b79537
--- /dev/null
+++ b/.github/workflows/chrome-extension.yml
@@ -0,0 +1,61 @@
+name: chrome-extension
+
+on:
+ pull_request:
+ paths:
+ - 'assets/apps_script/Code.gs'
+ - 'chrome-extension/**'
+ - '.github/workflows/chrome-extension.yml'
+ push:
+ branches:
+ - main
+ paths:
+ - 'assets/apps_script/Code.gs'
+ - 'chrome-extension/**'
+ - '.github/workflows/chrome-extension.yml'
+
+permissions:
+ contents: read
+
+jobs:
+ bundled-code-sync:
+ name: bundled Code.gs stays in sync
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Compare Apps Script source with extension fallback
+ run: |
+ if ! cmp -s assets/apps_script/Code.gs chrome-extension/Code.gs; then
+ echo "::error file=chrome-extension/Code.gs::chrome-extension/Code.gs must match assets/apps_script/Code.gs. Update the bundled fallback whenever the canonical Apps Script changes."
+ diff -u assets/apps_script/Code.gs chrome-extension/Code.gs || true
+ exit 1
+ fi
+
+ auth-key-smoke-test:
+ name: auth key smoke test
+ runs-on: ubuntu-latest
+ needs: bundled-code-sync
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install test dependencies
+ working-directory: chrome-extension
+ run: npm install
+
+ - name: Install Playwright browser
+ working-directory: chrome-extension
+ run: npx playwright install --with-deps chromium
+
+ - name: Run extension smoke tests
+ working-directory: chrome-extension
+ run: xvfb-run npm run test:smoke
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 51630c81..815ff156 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -46,7 +46,23 @@ permissions:
# and more importantly keeps target/ warm via the rust-cache action.
jobs:
+ verify-chrome-extension-codegs-sync:
+ name: verify chrome-extension Code.gs sync
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Compare Apps Script source with extension fallback
+ run: |
+ if ! cmp -s assets/apps_script/Code.gs chrome-extension/Code.gs; then
+ echo "::error file=chrome-extension/Code.gs::chrome-extension/Code.gs must match assets/apps_script/Code.gs. Update the bundled fallback whenever the canonical Apps Script changes."
+ diff -u assets/apps_script/Code.gs chrome-extension/Code.gs || true
+ exit 1
+ fi
+
build:
+ needs: verify-chrome-extension-codegs-sync
strategy:
fail-fast: false
matrix:
diff --git a/README.md b/README.md
index cd065e04..8c500f81 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,10 @@
- 🖥️ **Works on** Mac, Windows, Linux, Android, OpenWRT routers
- 🦊 **Any browser or app** that supports HTTP proxy or SOCKS5
+## Community helpers
+
+- **[mhrv-helper-extension](https://github.com/ardalan-ab/mhrv-helper-extension)** (Chrome, maintained by [@ardalan-ab](https://github.com/ardalan-ab)) — optional helper to generate an `AUTH_KEY`, copy `Code.gs`, and a `config.json` snippet for Apps Script mode. [How to use (English)](https://github.com/ardalan-ab/mhrv-helper-extension/blob/main/HOW_TO_USE.md) · [راهنمای فارسی](https://github.com/ardalan-ab/mhrv-helper-extension/blob/main/HOW_TO_USE.fa.md).
+
## How it works (the simple picture)
```
@@ -74,6 +78,8 @@ ISPs can't read inside encrypted HTTPS. They only see the address — `www.googl
11. Google shows a **Deployment ID** (a long random string). **Copy it** — you'll need it in Step 3.
> **Tip:** if you ever update `Code.gs` later, don't make a new deployment. Edit the code, then go to **Deploy → Manage deployments → ✏️ → Version: New version → Deploy**. The Deployment ID stays the same.
+>
+> **Optional:** use the community [Chrome extension](https://github.com/ardalan-ab/mhrv-helper-extension) — see [How to use](https://github.com/ardalan-ab/mhrv-helper-extension/blob/main/HOW_TO_USE.md) (English) or [راهنمای فارسی](https://github.com/ardalan-ab/mhrv-helper-extension/blob/main/HOW_TO_USE.fa.md).
### Step 2 — Download mhrv-rs
@@ -198,6 +204,10 @@ Most of the Rust code in this port was written with [Anthropic's Claude](https:/
- 🖥️ **روی** مک، ویندوز، لینوکس، اندروید، روتر OpenWRT کار میکند
- 🦊 **هر مرورگر یا برنامهای** که از HTTP proxy یا SOCKS5 پشتیبانی کند
+## ابزارهای جامعه
+
+- **[mhrv-helper-extension](https://github.com/ardalan-ab/mhrv-helper-extension)** (کروم، نگهداری [@ardalan-ab](https://github.com/ardalan-ab)) — افزونهٔ اختیاری برای تولید `AUTH_KEY`، کپی `Code.gs` و قطعهٔ `config.json` در حالت Apps Script. [راهنمای فارسی](https://github.com/ardalan-ab/mhrv-helper-extension/blob/main/HOW_TO_USE.fa.md) · [How to use (English)](https://github.com/ardalan-ab/mhrv-helper-extension/blob/main/HOW_TO_USE.md).
+
## چطور کار میکند (تصویر ساده)
```
@@ -244,6 +254,8 @@ ISP داخل HTTPS رمزشده را نمیتواند بخواند. فقط آ
۱۱. گوگل یک **Deployment ID** نشانت میدهد (یک رشتهٔ تصادفی طولانی). **کپیاش کن** — در مرحلهٔ ۳ لازم داری.
> **نکته:** اگر بعداً `Code.gs` را بهروزرسانی کنی، Deployment جدید نساز. کد را ویرایش کن، بعد **Deploy → Manage deployments → ✏️ → Version: New version → Deploy**. Deployment ID همان قبلی میماند.
+>
+> **اختیاری:** افزونهٔ اختیاری کروم [mhrv-helper-extension](https://github.com/ardalan-ab/mhrv-helper-extension) — [راهنمای فارسی](https://github.com/ardalan-ab/mhrv-helper-extension/blob/main/HOW_TO_USE.fa.md) · [How to use (English)](https://github.com/ardalan-ab/mhrv-helper-extension/blob/main/HOW_TO_USE.md).
### مرحلهٔ ۲ — دانلود mhrv-rs
diff --git a/chrome-extension/.github/workflows/sync-codegs.yml b/chrome-extension/.github/workflows/sync-codegs.yml
new file mode 100644
index 00000000..c62b7978
--- /dev/null
+++ b/chrome-extension/.github/workflows/sync-codegs.yml
@@ -0,0 +1,36 @@
+# Runs when this folder is the Git repository root (e.g. ardalan-ab/mhrv-helper-extension).
+# In the main monorepo, use .github/workflows/chrome-extension.yml instead (same cmp logic).
+
+name: Code.gs sync with upstream
+
+on:
+ push:
+ branches: [main, master]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ bundled-matches-upstream-main:
+ name: bundled Code.gs matches upstream main
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out extension repository
+ uses: actions/checkout@v4
+
+ - name: Fetch canonical Code.gs from main project (main branch)
+ run: |
+ curl -fsSL \
+ 'https://raw.githubusercontent.com/therealaleph/MasterHttpRelayVPN-RUST/main/assets/apps_script/Code.gs' \
+ -o /tmp/upstream-Code.gs
+
+ - name: Compare with bundled Code.gs
+ run: |
+ if ! cmp -s Code.gs /tmp/upstream-Code.gs; then
+ echo "::error::Bundled Code.gs differs from therealaleph/MasterHttpRelayVPN-RUST main (assets/apps_script/Code.gs). Copy the upstream file into this repo and commit."
+ diff -u /tmp/upstream-Code.gs Code.gs || true
+ exit 1
+ fi
+ echo "OK: bundled Code.gs matches upstream main."
diff --git a/chrome-extension/.gitignore b/chrome-extension/.gitignore
new file mode 100644
index 00000000..3658295b
--- /dev/null
+++ b/chrome-extension/.gitignore
@@ -0,0 +1,29 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+*.codex
+node_modules
+test-results
+playwright-report
+dist
+dist-ssr
+*.local
+*.env
+*.env.*.local
+!.env.example
+!.env.*.example
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/chrome-extension/Code.gs b/chrome-extension/Code.gs
new file mode 100644
index 00000000..13922255
--- /dev/null
+++ b/chrome-extension/Code.gs
@@ -0,0 +1,797 @@
+/**
+ * DomainFront Relay — Google Apps Script
+ *
+ * TWO modes:
+ * 1. Single: POST { k, m, u, h, b, ct, r } → { s, h, b }
+ * 2. Batch: POST { k, q: [{m,u,h,b,ct,r}, ...] } → { q: [{s,h,b}, ...] }
+ * Uses UrlFetchApp.fetchAll() — all URLs fetched IN PARALLEL.
+ *
+ * OPTIONAL SPREADSHEET-BACKED RESPONSE CACHE:
+ * Set CACHE_SPREADSHEET_ID to a valid Google Sheet ID (must be owned by
+ * the same account). When enabled, public GET requests are stored in the
+ * sheet and served from there on repeat visits, reducing UrlFetchApp
+ * quota consumption. Bodies are gzipped before base64 storage so larger
+ * responses fit under the per-cell character limit, and persistent
+ * 4xx (404/410/451) get a short negative-cache TTL so buggy clients
+ * that hammer dead URLs cost zero quota; 5xx is never cached so a
+ * flapping upstream cannot poison a 24h slot with a transient outage.
+ * The cache is Vary-aware (Accept-Encoding and Accept-Language are
+ * hashed into the compound cache key). Leave CACHE_SPREADSHEET_ID as-is
+ * to disable caching entirely — zero overhead.
+ *
+ * DEPLOYMENT:
+ * 1. Go to https://script.google.com → New project
+ * 2. Delete the default code, paste THIS entire file
+ * 3. Change AUTH_KEY below to your own secret
+ * 4. (Optional) Set CACHE_SPREADSHEET_ID to enable caching
+ * 5. Click Deploy → New deployment
+ * 6. Type: Web app | Execute as: Me | Who has access: Anyone
+ * 7. Copy the Deployment ID into config.json as "script_id"
+ *
+ * CHANGE THE AUTH KEY BELOW TO YOUR OWN SECRET!
+ */
+
+const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET";
+
+// Active-probing defense. When false (production default), bad AUTH_KEY
+// requests get a decoy HTML page that looks like a placeholder Apps
+// Script web app instead of the JSON `{"e":"unauthorized"}` body. This
+// makes the deployment indistinguishable from a forgotten-but-public
+// Apps Script project to active scanners that POST malformed payloads
+// looking for proxy endpoints.
+//
+// Set to `true` during initial setup if a misconfigured client is
+// hitting "unauthorized" and you want the explicit JSON error to debug
+// — then flip back to false before the deployment is widely shared.
+// (Inspired by #365 Section 3, mhrv-rs v1.8.0+.)
+const DIAGNOSTIC_MODE = false;
+
+// ── Optional Spreadsheet Cache ──────────────────────────────
+// Set to a valid Spreadsheet ID to enable response caching.
+// Leave as-is to disable caching entirely (zero overhead).
+const CACHE_SPREADSHEET_ID = "CHANGE_ME_TO_CACHE_SPREADSHEET_ID";
+const CACHE_SHEET_NAME = "RelayCache";
+const CACHE_META_SHEET_NAME = "RelayMeta";
+const CACHE_META_CURSOR_CELL = "A1";
+
+// ── Cache Tuning ────────────────────────────────────────────
+const CACHE_MAX_ROWS = 5000; // circular buffer capacity
+const CACHE_MAX_BODY_BYTES = 35000; // skip responses larger than ~35 KB
+const CACHE_DEFAULT_TTL_SECONDS = 86400; // 24-hour fallback when no Cache-Control
+
+// ── Negative Caching ────────────────────────────────────────
+// Persistent 4xx errors get a short TTL when the upstream is silent on
+// Cache-Control. Buggy clients hammer dead URLs (favicons, telemetry
+// pixels, dev-tools probes); a 5-minute floor absorbs the storm at
+// zero quota cost while letting transient 404s self-heal quickly.
+// 5xx is never cached — see _fetchAndCache.
+const NEGATIVE_CACHE_STATUSES = { 404: 1, 410: 1, 451: 1 };
+const NEGATIVE_CACHE_TTL_SECONDS = 300;
+
+// ── Body Compression ────────────────────────────────────────
+// Bodies are gzipped before base64 storage when worthwhile. Gzip has
+// ~20 bytes of header overhead, so very small payloads can bloat;
+// skip below this threshold. Already-encoded responses (gzip/br/etc.)
+// are stored as-is to avoid double-compression.
+const GZIP_MIN_BYTES = 256;
+
+// ── Vary-Aware Cache Key ────────────────────────────────────
+// These request headers are hashed into the compound cache key
+// alongside the URL so that responses with different encodings
+// or languages never collide in the cache. Covers ~95 % of
+// real-world Vary usage without inspecting the response.
+const VARY_KEY_HEADERS = ["accept-encoding", "accept-language"];
+
+// Connection-level + IP-leak request headers we strip before forwarding
+// to the destination. Browser capability headers (sec-ch-ua*, sec-fetch-*)
+// stay intact — modern apps like Google Meet use them for browser gating.
+// We also drop the `X-Forwarded-*` / `Forwarded` / `Via` family so a
+// misconfigured upstream proxy on the user side can't leak the user's
+// real IP through the relay path. Mirrors upstream
+// `masterking32/MasterHttpRelayVPN@3094288`.
+const SKIP_HEADERS = {
+ host: 1, connection: 1, "content-length": 1,
+ "transfer-encoding": 1, "proxy-connection": 1, "proxy-authorization": 1,
+ "priority": 1, te: 1,
+ "x-forwarded-for": 1, "x-forwarded-host": 1, "x-forwarded-proto": 1,
+ "x-forwarded-port": 1, "x-real-ip": 1, "forwarded": 1, "via": 1,
+};
+
+// Methods we consider safe to replay if `UrlFetchApp.fetchAll()` raises.
+// GET/HEAD/OPTIONS are idempotent per RFC 9110; POST/PUT/PATCH/DELETE
+// can have side-effects so we surface the error instead of silently
+// re-firing them.
+const SAFE_REPLAY_METHODS = { GET: 1, HEAD: 1, OPTIONS: 1 };
+
+// Headers that disqualify a request from the cache path.
+const CACHE_BUSTING_HEADERS = {
+ authorization: 1, cookie: 1, "x-api-key": 1,
+ "proxy-authorization": 1, "set-cookie": 1,
+};
+
+// HTML body for the bad-auth decoy. Mimics a minimal Apps Script-style
+// placeholder page — no proxy-shaped JSON, nothing distinctive enough
+// for a probe to fingerprint as a tunnel endpoint.
+const DECOY_HTML =
+ '
Web App' +
+ '
The script completed but did not return anything.
' +
+ '';
+
+// ── Request Handlers ────────────────────────────────────────
+
+function _decoyOrError(jsonBody) {
+ if (DIAGNOSTIC_MODE) return _json(jsonBody);
+ return ContentService
+ .createTextOutput(DECOY_HTML)
+ .setMimeType(ContentService.MimeType.HTML);
+}
+
+function doPost(e) {
+ try {
+ var req = JSON.parse(e.postData.contents);
+ if (req.k !== AUTH_KEY) return _decoyOrError({ e: "unauthorized" });
+
+ // Batch mode: { k, q: [...] }
+ if (Array.isArray(req.q)) return _doBatch(req.q);
+
+ // Single mode
+ return _doSingle(req);
+ } catch (err) {
+ // Parse failures of the request body are also probe-shaped — a real
+ // mhrv-rs client never sends invalid JSON. Decoy for the same reason.
+ return _decoyOrError({ e: String(err) });
+ }
+}
+
+// `doGet` is what active scanners hit first (HTTP GET probes are cheaper
+// than POSTs). Apps Script defaults to a "Script function not found" page
+// here which is a fine-enough decoy on its own, but explicitly returning
+// the same harmless placeholder makes the response identical to the
+// bad-auth POST decoy — one less fingerprint vector.
+function doGet(e) {
+ return ContentService
+ .createTextOutput(DECOY_HTML)
+ .setMimeType(ContentService.MimeType.HTML);
+}
+
+// ── Single Request ─────────────────────────────────────────
+
+function _doSingle(req) {
+ if (!req.u || typeof req.u !== "string" || !req.u.match(/^https?:\/\//i)) {
+ return _json({ e: "bad url" });
+ }
+
+ // ── Optional cache path ────────────────────────────────
+ // Only entered when CACHE_SPREADSHEET_ID is configured and
+ // the request qualifies as a public, cachable GET.
+ if (_canUseCache(req)) {
+ var cached = _getFromCache(req.u, req.h);
+ if (cached) {
+ return _json({
+ s: cached.status,
+ h: JSON.parse(cached.headers),
+ b: cached.body,
+ cached: true,
+ });
+ }
+
+ var fetchResult = _fetchAndCache(req.u, req.h);
+ if (fetchResult) {
+ return _json({
+ s: fetchResult.status,
+ h: JSON.parse(fetchResult.headers),
+ b: fetchResult.body,
+ cached: false,
+ });
+ }
+ // If _fetchAndCache returns null (spreadsheet unavailable),
+ // fall through to the normal relay path below.
+ }
+
+ // ── Normal relay (cache disabled or unavailable) ────────
+ // Wrap the fetch + body encode in try/catch so any failure surfaces as
+ // a JSON error envelope the Rust client can parse. Without this, throws
+ // from UrlFetchApp.fetch (URL too long, payload too large, quota
+ // exhausted, 6-minute execution timeout) or from base64Encode (response
+ // body near Apps Script's ~50 MB ceiling can blow the V8 heap during
+ // encode) propagate unhandled, and Apps Script serves its default
+ // `Web App` HTML error page — which the client then
+ // reports as "Relay failed: bad response: no json in: Web App>..."
+ // and the user has no signal as to the actual cause. Mirrors the
+ // per-item try/catch in _doBatch below.
+ try {
+ var opts = _buildOpts(req);
+ var resp = UrlFetchApp.fetch(req.u, opts);
+ return _json({
+ s: resp.getResponseCode(),
+ h: _respHeaders(resp),
+ b: Utilities.base64Encode(resp.getContent()),
+ });
+ } catch (err) {
+ return _json({ e: "fetch failed: " + String(err) });
+ }
+}
+
+// ── Batch Request ──────────────────────────────────────────
+
+function _doBatch(items) {
+ var fetchArgs = [];
+ var fetchIndex = [];
+ var fetchMethods = [];
+ var errorMap = {};
+
+ for (var i = 0; i < items.length; i++) {
+ var item = items[i];
+ if (!item || typeof item !== "object") {
+ errorMap[i] = "bad item";
+ continue;
+ }
+ if (!item.u || typeof item.u !== "string" || !item.u.match(/^https?:\/\//i)) {
+ errorMap[i] = "bad url";
+ continue;
+ }
+ try {
+ var opts = _buildOpts(item);
+ opts.url = item.u;
+ fetchArgs.push(opts);
+ fetchIndex.push(i);
+ fetchMethods.push(String(item.m || "GET").toUpperCase());
+ } catch (buildErr) {
+ errorMap[i] = String(buildErr);
+ }
+ }
+
+ // fetchAll() processes all requests in parallel inside Google. If it
+ // throws as a whole (e.g. one URL violates UrlFetchApp limits and
+ // poisons the whole batch), degrade to per-item fetch on safe methods
+ // so a single bad request does not zero out every response in the
+ // batch. Mirrors upstream `masterking32/MasterHttpRelayVPN@3094288`.
+ var responses = [];
+ if (fetchArgs.length > 0) {
+ try {
+ responses = UrlFetchApp.fetchAll(fetchArgs);
+ } catch (fetchAllErr) {
+ responses = [];
+ for (var j = 0; j < fetchArgs.length; j++) {
+ try {
+ if (!SAFE_REPLAY_METHODS[fetchMethods[j]]) {
+ errorMap[fetchIndex[j]] =
+ "batch fetchAll failed; unsafe method not replayed";
+ responses[j] = null;
+ continue;
+ }
+ var fallbackReq = fetchArgs[j];
+ var fallbackUrl = fallbackReq.url;
+ var fallbackOpts = {};
+ for (var key in fallbackReq) {
+ if (
+ Object.prototype.hasOwnProperty.call(fallbackReq, key) &&
+ key !== "url"
+ ) {
+ fallbackOpts[key] = fallbackReq[key];
+ }
+ }
+ responses[j] = UrlFetchApp.fetch(fallbackUrl, fallbackOpts);
+ } catch (singleErr) {
+ errorMap[fetchIndex[j]] = String(singleErr);
+ responses[j] = null;
+ }
+ }
+ }
+ }
+
+ var results = [];
+ var rIdx = 0;
+ for (var i = 0; i < items.length; i++) {
+ if (Object.prototype.hasOwnProperty.call(errorMap, i)) {
+ results.push({ e: errorMap[i] });
+ } else {
+ var resp = responses[rIdx++];
+ if (!resp) {
+ results.push({ e: "fetch failed" });
+ } else {
+ results.push({
+ s: resp.getResponseCode(),
+ h: _respHeaders(resp),
+ b: Utilities.base64Encode(resp.getContent()),
+ });
+ }
+ }
+ }
+ return _json({ q: results });
+}
+
+// ── Request Building ───────────────────────────────────────
+
+function _buildOpts(req) {
+ var opts = {
+ method: (req.m || "GET").toLowerCase(),
+ muteHttpExceptions: true,
+ followRedirects: req.r !== false,
+ validateHttpsCertificates: true,
+ escaping: false,
+ };
+ if (req.h && typeof req.h === "object") {
+ var headers = {};
+ for (var k in req.h) {
+ if (req.h.hasOwnProperty(k) && !SKIP_HEADERS[k.toLowerCase()]) {
+ headers[k] = req.h[k];
+ }
+ }
+ opts.headers = headers;
+ }
+ if (req.b) {
+ opts.payload = Utilities.base64Decode(req.b);
+ if (req.ct) opts.contentType = req.ct;
+ }
+ return opts;
+}
+
+function _respHeaders(resp) {
+ try {
+ if (typeof resp.getAllHeaders === "function") {
+ return resp.getAllHeaders();
+ }
+ } catch (err) {}
+ return resp.getHeaders();
+}
+
+function _json(obj) {
+ return ContentService.createTextOutput(JSON.stringify(obj)).setMimeType(
+ ContentService.MimeType.JSON
+ );
+}
+
+// ═══════════════════════════════════════════════════════════
+// SPREADSHEET CACHE — SHEET MANAGEMENT
+// ═══════════════════════════════════════════════════════════
+
+function _initCacheSheet() {
+ if (CACHE_SPREADSHEET_ID === "CHANGE_ME_TO_CACHE_SPREADSHEET_ID") {
+ return null;
+ }
+ try {
+ var ss = SpreadsheetApp.openById(CACHE_SPREADSHEET_ID);
+ var sheet = ss.getSheetByName(CACHE_SHEET_NAME);
+ if (!sheet) {
+ sheet = ss.insertSheet(CACHE_SHEET_NAME);
+ // Schema: URL_Hash | URL | Status | Headers | Body | Timestamp | Expires_At | Z
+ // Z is 1 when Body is base64(gzip(rawBytes)), 0/empty when base64(rawBytes).
+ // Legacy 7-column rows from older deployments read back as Z=undefined,
+ // which the cache hit path treats as "not gzipped" — fully compatible.
+ sheet.getRange(1, 1, 1, 8).setValues([[
+ "URL_Hash", "URL", "Status", "Headers", "Body", "Timestamp", "Expires_At", "Z"
+ ]]);
+ }
+ return sheet;
+ } catch (e) {
+ return null;
+ }
+}
+
+function _getMetaSheet() {
+ if (CACHE_SPREADSHEET_ID === "CHANGE_ME_TO_CACHE_SPREADSHEET_ID") {
+ return null;
+ }
+ try {
+ var ss = SpreadsheetApp.openById(CACHE_SPREADSHEET_ID);
+ var sheet = ss.getSheetByName(CACHE_META_SHEET_NAME);
+ if (!sheet) {
+ sheet = ss.insertSheet(CACHE_META_SHEET_NAME);
+ sheet.getRange(CACHE_META_CURSOR_CELL).setValue(2);
+ sheet.hideSheet();
+ }
+ return sheet;
+ } catch (e) {
+ return null;
+ }
+}
+
+function _getNextCursor(sheet, metaSheet) {
+ var cursorRange = metaSheet.getRange(CACHE_META_CURSOR_CELL);
+ var cursor = cursorRange.getValue();
+ if (typeof cursor !== "number" || cursor < 2) cursor = 2;
+
+ var totalRows = sheet.getDataRange().getNumRows();
+
+ if (totalRows < CACHE_MAX_ROWS + 1) {
+ return totalRows + 1;
+ }
+
+ return cursor;
+}
+
+function _advanceCursor(metaSheet, currentRow) {
+ var nextRow = currentRow + 1;
+ if (nextRow > CACHE_MAX_ROWS + 1) nextRow = 2;
+ metaSheet.getRange(CACHE_META_CURSOR_CELL).setValue(nextRow);
+}
+
+function _ensureRowsAllocated(sheet) {
+ var totalRows = sheet.getDataRange().getNumRows();
+ if (totalRows < CACHE_MAX_ROWS + 1) {
+ var needed = CACHE_MAX_ROWS + 1 - totalRows;
+ sheet.insertRowsAfter(totalRows, needed);
+ }
+}
+
+// ═══════════════════════════════════════════════════════════
+// SPREADSHEET CACHE — VARY-AWARE COMPOUND KEY
+// ═══════════════════════════════════════════════════════════
+
+/**
+ * Case-insensitive header lookup.
+ * HTTP header names are case-insensitive per RFC 7230 § 3.2.
+ */
+function _getHeaderCaseInsensitive(headers, targetKey) {
+ var target = targetKey.toLowerCase();
+ for (var k in headers) {
+ if (headers.hasOwnProperty(k) && k.toLowerCase() === target) {
+ return headers[k];
+ }
+ }
+ return null;
+}
+
+/**
+ * Compute a compound cache key:
+ * MD5(URL | header1:value1 | header2:value2 | ...)
+ *
+ * Instead of reading the response Vary header (which would require
+ * fetching first — circular), we preemptively include the request
+ * headers that are known to cause response variation. This handles
+ * Vary: Accept-Encoding and Vary: Accept-Language without ever
+ * inspecting the response.
+ *
+ * Values are lowercased and whitespace-stripped so semantically
+ * identical requests from different clients produce the same hash.
+ * Missing and empty headers both map to "" (same semantic).
+ */
+function _getCacheKey(url, reqHeaders) {
+ var parts = [url];
+
+ if (reqHeaders && typeof reqHeaders === "object") {
+ for (var i = 0; i < VARY_KEY_HEADERS.length; i++) {
+ var headerName = VARY_KEY_HEADERS[i];
+ var rawValue = _getHeaderCaseInsensitive(reqHeaders, headerName);
+
+ if (rawValue && String(rawValue).trim() !== "") {
+ parts.push(headerName + ":" + rawValue.toLowerCase().replace(/\s/g, ""));
+ } else {
+ parts.push(headerName + ":");
+ }
+ }
+ } else {
+ for (var j = 0; j < VARY_KEY_HEADERS.length; j++) {
+ parts.push(VARY_KEY_HEADERS[j] + ":");
+ }
+ }
+
+ var compoundKey = parts.join("|");
+ return _md5Hex(compoundKey);
+}
+
+function _md5Hex(input) {
+ var rawHash = Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, input);
+ return rawHash
+ .map(function (byte) {
+ var v = (byte < 0) ? 256 + byte : byte;
+ return ("0" + v.toString(16)).slice(-2);
+ })
+ .join("");
+}
+
+// ═══════════════════════════════════════════════════════════
+// SPREADSHEET CACHE — CORE LOGIC
+// ═══════════════════════════════════════════════════════════
+
+/**
+ * Returns true if the request is eligible for the cache path:
+ * public GET, no body, no auth/cookie headers, cache configured.
+ */
+function _canUseCache(req) {
+ if ((req.m || "GET") !== "GET") return false;
+ if (req.b) return false;
+ if (!req.u || !req.u.match(/^https?:\/\//i)) return false;
+ if (CACHE_SPREADSHEET_ID === "CHANGE_ME_TO_CACHE_SPREADSHEET_ID") return false;
+
+ if (req.h && typeof req.h === "object") {
+ for (var k in req.h) {
+ if (req.h.hasOwnProperty(k) && CACHE_BUSTING_HEADERS[k.toLowerCase()]) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Extract max-age (seconds) from a Cache-Control header value.
+ * Returns 0 if the directive forbids caching (no-cache / no-store /
+ * private). Falls back to CACHE_DEFAULT_TTL_SECONDS when no header
+ * is present. Clamped to [60, 2592000] (1 min – 30 days).
+ */
+function _parseMaxAge(cacheControlHeader) {
+ if (!cacheControlHeader) return CACHE_DEFAULT_TTL_SECONDS;
+
+ var lower = cacheControlHeader.toLowerCase();
+
+ if (
+ lower.indexOf("no-cache") !== -1 ||
+ lower.indexOf("no-store") !== -1 ||
+ lower.indexOf("private") !== -1
+ ) {
+ return 0;
+ }
+
+ var match = lower.match(/max-age=(\d+)/);
+ if (match) {
+ var ttl = parseInt(match[1], 10);
+ return Math.max(60, Math.min(ttl, 2592000));
+ }
+
+ return CACHE_DEFAULT_TTL_SECONDS;
+}
+
+/**
+ * Rewrite time-sensitive headers so the client sees accurate
+ * Date, Age, and Cache-Control values reflecting cache age.
+ */
+function _refreshCachedHeaders(headersJson, timestamp) {
+ var headers = JSON.parse(headersJson);
+ var cachedAt = new Date(timestamp);
+ var now = new Date();
+ var ageSeconds = Math.floor((now.getTime() - cachedAt.getTime()) / 1000);
+
+ if (ageSeconds < 0) ageSeconds = 0;
+
+ headers["Date"] = now.toUTCString();
+ headers["Age"] = String(ageSeconds);
+
+ var originalCc = headers["Cache-Control"] || headers["cache-control"];
+ if (originalCc) {
+ headers["X-Original-Cache-Control"] = originalCc;
+ }
+
+ var remainingMaxAge = Math.max(0, _parseMaxAge(originalCc) - ageSeconds);
+ headers["Cache-Control"] = "public, max-age=" + remainingMaxAge;
+
+ headers["X-Cache"] = "HIT from relay-spreadsheet";
+ headers["X-Cached-At"] = cachedAt.toUTCString();
+
+ return JSON.stringify(headers);
+}
+
+/**
+ * Retrieve a cached response by compound cache key.
+ * Uses TextFinder for O(log n) lookup. Skips expired entries.
+ * Returns null on miss, expired entry, or unavailable sheet.
+ */
+function _getFromCache(url, reqHeaders) {
+ var sheet = _initCacheSheet();
+ if (!sheet) return null;
+
+ var hash = _getCacheKey(url, reqHeaders);
+ var finder = sheet.createTextFinder(hash).matchEntireCell(true);
+ var found = finder.findNext();
+
+ if (found) {
+ // 8-column read. Legacy 7-column rows return undefined for the Z slot,
+ // which is falsy and falls through the not-gzipped branch below — fully
+ // compatible with caches written before the gzip-storage change.
+ var row = sheet.getRange(found.getRow(), 1, 1, 8).getValues()[0];
+
+ var expiresAt = row[6];
+ if (expiresAt && expiresAt instanceof Date && expiresAt < new Date()) {
+ return null;
+ }
+
+ var storedBody = row[4];
+ var body;
+ if (row[7]) {
+ // Stored as base64(gzip(rawBytes)). The relay protocol's `b` field
+ // is base64(rawBytes), so decompress and re-encode for the wire.
+ var gzipped = Utilities.base64Decode(storedBody);
+ var raw = Utilities
+ .ungzip(Utilities.newBlob(gzipped, "application/x-gzip"))
+ .getBytes();
+ body = Utilities.base64Encode(raw);
+ } else {
+ body = storedBody;
+ }
+
+ return {
+ status: row[2],
+ headers: _refreshCachedHeaders(row[3], row[5]),
+ body: body,
+ };
+ }
+ return null;
+}
+
+/**
+ * Fetch a URL and store the response in the spreadsheet cache
+ * using a circular buffer (O(1) writes). Skips storage on 5xx
+ * (transient outages must not poison a 24h slot), when Cache-Control
+ * forbids caching, or when the post-compression body exceeds
+ * CACHE_MAX_BODY_BYTES. Always returns the fetch result so the caller
+ * can serve the live response even when the cache write is skipped.
+ */
+function _fetchAndCache(url, reqHeaders) {
+ var sheet = _initCacheSheet();
+ if (!sheet) return null;
+
+ try {
+ var response = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
+ var status = response.getResponseCode();
+ var headers = _respHeaders(response);
+ var bodyBytes = response.getContent();
+ var rawB64 = Utilities.base64Encode(bodyBytes);
+ var headersJson = JSON.stringify(headers);
+ var liveResult = { status: status, headers: headersJson, body: rawB64 };
+
+ // 5xx never enters the cache. A flapping upstream returning 503 once
+ // would otherwise pin that response for 24h and break the URL for
+ // every subsequent client until expiry.
+ if (status >= 500) return liveResult;
+
+ var cacheControl =
+ headers["Cache-Control"] || headers["cache-control"] || null;
+ var ttlSeconds = _parseMaxAge(cacheControl);
+
+ if (ttlSeconds === 0) return liveResult;
+
+ // Negative caching: cap TTL on persistent 4xx when upstream is silent.
+ // If they explicitly stated a max-age for the 404, we honor it instead
+ // — the origin knows best when it spoke up.
+ if (NEGATIVE_CACHE_STATUSES[status] && !cacheControl) {
+ ttlSeconds = NEGATIVE_CACHE_TTL_SECONDS;
+ }
+
+ // Decide whether to gzip-store. Skip when upstream is already encoded
+ // (avoids double-compressing gzip/br/zstd payloads) and when the body
+ // is too small to overcome gzip's header overhead.
+ var contentEncoding = String(
+ headers["Content-Encoding"] || headers["content-encoding"] || ""
+ ).toLowerCase();
+ var alreadyEncoded = contentEncoding && contentEncoding !== "identity";
+ var storedBody;
+ var storedZ;
+ if (alreadyEncoded || bodyBytes.length < GZIP_MIN_BYTES) {
+ storedBody = rawB64;
+ storedZ = 0;
+ } else {
+ storedBody = Utilities.base64Encode(
+ Utilities.gzip(Utilities.newBlob(bodyBytes)).getBytes()
+ );
+ storedZ = 1;
+ }
+
+ // Cell-size safety gate, applied after compression so that a 100 KB
+ // text body that gzips to ~15 KB now fits where it previously bailed.
+ if (storedBody.length > CACHE_MAX_BODY_BYTES) return liveResult;
+
+ var hash = _getCacheKey(url, reqHeaders);
+ var timestamp = new Date();
+ var expiresAt = new Date(timestamp.getTime() + ttlSeconds * 1000);
+
+ // Safety: fallback if Date math produces invalid result
+ if (isNaN(expiresAt.getTime())) {
+ expiresAt = new Date(timestamp.getTime() + CACHE_DEFAULT_TTL_SECONDS * 1000);
+ }
+
+ var rowData = [
+ hash,
+ url,
+ status,
+ headersJson,
+ storedBody,
+ timestamp.toISOString(),
+ expiresAt,
+ storedZ,
+ ];
+
+ // Circular buffer write (O(1))
+ var metaSheet = _getMetaSheet();
+ if (metaSheet) {
+ _ensureRowsAllocated(sheet);
+ var writeRow = _getNextCursor(sheet, metaSheet);
+ sheet.getRange(writeRow, 1, 1, 8).setValues([rowData]);
+ _advanceCursor(metaSheet, writeRow);
+ } else {
+ // Fallback: simple append if meta sheet is unavailable
+ sheet.appendRow(rowData);
+ }
+
+ return liveResult;
+ } catch (e) {
+ return null;
+ }
+}
+
+// ═══════════════════════════════════════════════════════════
+// SPREADSHEET CACHE — DIAGNOSTICS
+// ═══════════════════════════════════════════════════════════
+
+function getCacheStats() {
+ var sheet = _initCacheSheet();
+ if (!sheet) {
+ console.log("Cache is not enabled or spreadsheet unavailable.");
+ return;
+ }
+
+ var data = sheet.getDataRange().getValues();
+ var totalEntries = data.length - 1;
+ var now = new Date();
+ var expiredCount = 0;
+
+ for (var i = 1; i < data.length; i++) {
+ var expiresAt = data[i][6];
+ if (expiresAt && expiresAt instanceof Date && expiresAt < now) {
+ expiredCount++;
+ }
+ }
+
+ var metaSheet = _getMetaSheet();
+ var cursorInfo = "N/A";
+ if (metaSheet) {
+ cursorInfo = String(metaSheet.getRange(CACHE_META_CURSOR_CELL).getValue());
+ }
+
+ console.log("=== CACHE STATS ===");
+ console.log("Total rows used: " + totalEntries + " / " + CACHE_MAX_ROWS);
+ console.log("Active entries: " + (totalEntries - expiredCount));
+ console.log("Expired entries: " + expiredCount);
+ console.log("Cursor position: " + cursorInfo);
+ console.log("Max body size: " + CACHE_MAX_BODY_BYTES + " chars");
+ console.log("Default TTL: " + CACHE_DEFAULT_TTL_SECONDS + " sec");
+ console.log("Vary key headers: " + VARY_KEY_HEADERS.join(", "));
+ if (totalEntries > 0) {
+ console.log("Oldest entry: " + data[1][5]);
+ console.log("Newest entry: " + data[data.length - 1][5]);
+ }
+}
+
+function clearExpiredCache() {
+ var sheet = _initCacheSheet();
+ if (!sheet) {
+ console.log("Cache is not enabled.");
+ return;
+ }
+
+ var data = sheet.getDataRange().getValues();
+ var now = new Date();
+ var rowsToClear = [];
+
+ for (var i = 1; i < data.length; i++) {
+ var expiresAt = data[i][6];
+ if (expiresAt && expiresAt instanceof Date && expiresAt < now) {
+ rowsToClear.push(i + 1);
+ }
+ }
+
+ for (var j = 0; j < rowsToClear.length; j++) {
+ sheet.getRange(rowsToClear[j], 1, 1, 8).clearContent();
+ }
+
+ console.log("Cleared " + rowsToClear.length + " expired entries (" +
+ (data.length - 1 - rowsToClear.length) + " remaining).");
+}
+
+function clearEntireCache() {
+ var sheet = _initCacheSheet();
+ if (sheet) {
+ var totalRows = sheet.getDataRange().getNumRows();
+ if (totalRows > 1) {
+ sheet.getRange(2, 1, totalRows - 1, 8).clearContent();
+ }
+ }
+
+ var metaSheet = _getMetaSheet();
+ if (metaSheet) {
+ metaSheet.getRange(CACHE_META_CURSOR_CELL).setValue(2);
+ }
+
+ console.log("Cache wiped. Cursor reset to row 2.");
+}
diff --git a/chrome-extension/HOW_TO_USE.fa.md b/chrome-extension/HOW_TO_USE.fa.md
new file mode 100644
index 00000000..0a19c738
--- /dev/null
+++ b/chrome-extension/HOW_TO_USE.fa.md
@@ -0,0 +1,111 @@
+# راهنمای استفاده از افزونهٔ کمککنندهٔ Apps Script برای mhrv-rs (کروم)
+
+[English](HOW_TO_USE.md)
+
+این راهنما برای **کاربر نهایی** است که میخواهد با این افزونه، راهاندازی Apps Script برای [mhrv-rs](https://github.com/therealaleph/MasterHttpRelayVPN-RUST) را سادهتر کند. اسکریپت را هنوز باید در **حساب گوگل خودت** دیپلوی کنی و **mhrv-rs** را روی کامپیوترت اجرا کنی؛ افزونه فقط متن آماده میکند و لینک باز میکند.
+
+---
+
+## ۱. نصب افزونه (حالت unpacked)
+
+1. پوشهٔ افزونه را بگیر (پوشهای که `manifest.json` داخلش است):
+ - **پیشنهادی:** مخزن [ardalan-ab/mhrv-helper-extension](https://github.com/ardalan-ab/mhrv-helper-extension) را کلون کن و **ریشهٔ مخزن** را انتخاب کن.
+ - **جایگزین:** در مخزن اصلی [MasterHttpRelayVPN-RUST](https://github.com/therealaleph/MasterHttpRelayVPN-RUST) از پوشهٔ **`chrome-extension/`** استفاده کن.
+2. کروم را باز کن و به `chrome://extensions` برو.
+3. **Developer mode** را روشن کن (بالا سمت راست).
+4. **Load unpacked** را بزن و همان پوشه را انتخاب کن.
+5. (اختیاری) از آیکن پازل، افزونه را **Pin** کن تا همیشه در نوار ابزار باشد.
+
+---
+
+## ۲. باز کردن پاپآپ و انتخاب زبان
+
+1. روی آیکن افزونه کلیک کن.
+2. در بالای پاپآپ، **English** یا **فارسی** را انتخاب کن. برای فارسی، چیدمان راستبهچپ است.
+
+---
+
+## ۳. دانلود mhrv-rs (اختیاری ولی مفید)
+
+در **مرحلهٔ ۰**:
+
+- **دانلود mhrv-rs** را بزن — کروم فایل ریلیز مناسب سیستمعاملت را از گیتهاب باز میکند.
+- یا **مشاهدهٔ همهٔ انتشارها** را بزن اگر میخواهی خودت بیلد دیگری (اندروید، musl و غیره) را انتخاب کنی.
+
+فایل را از حالت فشرده خارج کن و جایی نگه دار که بعداً برنامه را از همانجا اجرا کنی (طبق README اصلی پروژه).
+
+---
+
+## ۴. تولید کلید احراز هویت
+
+در **مرحلهٔ ۱**:
+
+1. **تولید کلید احراز هویت** را بزن. یک رشتهٔ hex به طول ۶۴ کاراکتر در کادر ظاهر میشود.
+2. در صورت نیاز **کپی کلید** را بزن.
+
+این مقدار را در **دو جا** یکسان استفاده کن:
+
+- داخل **Apps Script** بهعنوان `AUTH_KEY` در `Code.gs` (وقتی **کپی Code.gs** میزنی، افزونه آن را در کد قرار میدهد).
+- در **mhrv-rs** در فیلد auth key هنگام ذخیرهٔ کانفیگ.
+
+مثل پسورد نگهاش دار. افزونه آن را در مرورگر میسازد و به سروری ارسال نمیکند.
+
+---
+
+## ۵. بردن Code.gs به Google Apps Script
+
+در **مرحلهٔ ۲**:
+
+1. تا زمانی که اسپینر تمام شود و اسکریپت بارگذاری شود صبر کن (از گیتهاب، یا نسخهٔ داخل بسته اگر raw مسدود باشد).
+2. (اختیاری) **بررسی آخرین نسخه Code.gs** را بزن تا ببینی نسخهٔ بسته با upstream یکی است یا نه.
+3. در صورت نیاز **باز کردن Apps Script** را بزن تا تب پروژهٔ جدید باز شود.
+4. در Apps Script: **New project** (یا پروژهٔ موجود)، محتوای پیشفرض `Code.gs` را پاک کن.
+5. در افزونه، **کپی Code.gs** (یا **دانلود Code.gs** و باز کردن فایل) را بزن.
+6. در ویرایشگر Apps Script پیست کن و **Save** بزن.
+
+بهصورت **Web app** مستقر کن (مثل README سریع اصلی): معمولاً **Execute as: Me** و **Who has access: Anyone** (مگر راهنما چیز دیگری بگوید). اگر گوگل دسترسی خواست، تأیید کن.
+
+**Deployment ID** که گوگل نشان میدهد را کپی کن (در مرحلهٔ ۳ افزونه میچسبانی).
+
+---
+
+## ۶. ساخت قطعهٔ کانفیگ محلی
+
+در **مرحلهٔ ۳**:
+
+1. **شناسهٔ استقرار** را در فیلد مربوطه پیست کن.
+2. **کپی قطعهٔ پیکربندی** را بزن.
+3. JSON را در **`config.json`** مربوط به `mhrv-rs` ادغام کن (یا در UI گرافیکی برنامه اگر از آن استفاده میکنی).
+
+شکل معمول:
+
+```json
+{
+ "mode": "apps_script",
+ "script_id": "شناسه_استقرار_تو",
+ "auth_key": "کلید_تو",
+ "listen_port": 8085
+}
+```
+
+افزونه از همان چیزی که تولید کردهای و Deployment ID که دادی پرش میکند.
+
+---
+
+## ۷. اجرای mhrv-rs و تنظیم پروکسی مرورگر
+
+از بخش «اجرای اول» به بعد **README اصلی پروژه** را دنبال کن: ذخیرهٔ کانفیگ، استارت پروکسی، تنظیم فایرفاکس یا کروم روی `127.0.0.1:8085` (یا SwitchyOmega طبق همان README).
+
+در پاپآپ از **مشاهدهٔ مستندات** و **راهنمای راهاندازی** برای لینک به README و راهنمای رسمی استفاده کن.
+
+---
+
+## عیبیابی کوتاه
+
+| مشکل | کار پیشنهادی |
+|--------|----------------|
+| اسکریپت بارگذاری نمیشود | ممکن است raw گیتهاب مسدود باشد؛ افزونه به `Code.gs` داخل بسته برمیگردد. بعد از بهبود شبکه **بررسی آخرین نسخه Code.gs** را بزن. |
+| دکمههای کپی کار نمیکنند | بعضی محیطها کلیپبورد را مسدود میکنند؛ پاپآپ را فوکوس کن یا از **دانلود Code.gs** استفاده کن. |
+| باینری اشتباه دانلود شد | با **مشاهدهٔ همهٔ انتشارها** دستی فایل مناسب سیستمعاملت را انتخاب کن. |
+
+برای موضوعات پیشرفتهتر (CAPTCHA، تلگرام، Full Tunnel) از [راهنمای کامل](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/docs/guide.fa.md) در مخزن اصلی استفاده کن.
diff --git a/chrome-extension/HOW_TO_USE.md b/chrome-extension/HOW_TO_USE.md
new file mode 100644
index 00000000..35d431b9
--- /dev/null
+++ b/chrome-extension/HOW_TO_USE.md
@@ -0,0 +1,111 @@
+# How to use the mhrv-rs Apps Script Helper (Chrome)
+
+[فارسی](HOW_TO_USE.fa.md)
+
+This guide is for **end users** who want the extension to help with Apps Script setup for [mhrv-rs](https://github.com/therealaleph/MasterHttpRelayVPN-RUST). You still deploy the script in **your** Google account and run **mhrv-rs** on your computer; the extension only prepares text and opens links.
+
+---
+
+## 1. Install the extension (unpacked)
+
+1. Get the extension folder (the directory that contains `manifest.json`):
+ - **Recommended:** clone [ardalan-ab/mhrv-helper-extension](https://github.com/ardalan-ab/mhrv-helper-extension) and use the **repo root**.
+
+2. Go to ( chrome://extensions/ )
+3. Click on **Devloper mode**
+4. Click **Load unpacked** and select that folder.
+5. (Optional) Pin the extension from the puzzle icon so it stays in the toolbar.
+
+---
+
+## 2. Open the popup and pick a language
+
+1. Click the extension icon.
+2. In the header, choose **English** or **فارسی**. Persian uses RTL layout.
+
+---
+
+## 3. Download mhrv-rs (optional but useful)
+
+In **Step 0**:
+
+- Click **Download mhrv-rs** — Chrome opens the release asset the extension picks for your OS (from GitHub).
+- Or use **View all releases** if you want to choose another build (Android, musl, etc.).
+
+Unzip the archive and keep the folder somewhere you will run the app from (see the main project README).
+
+---
+
+## 4. Generate the auth key
+
+In **Step 1**:
+
+1. Click **Generate auth key**. A 64-character hex string appears in the box.
+2. Click **Copy key** if you want it on the clipboard.
+
+Use this same value in two places:
+
+- Inside **Apps Script** as `AUTH_KEY` in `Code.gs` (the extension’s **Copy Code.gs** step will embed it for you when you copy).
+- In **mhrv-rs** as the auth key field when you save config.
+
+Treat it like a password. The extension generates it in the browser; it is not sent to a server by the extension.
+
+---
+
+## 5. Get Code.gs into Google Apps Script
+
+In **Step 2**:
+
+1. Wait until the spinner disappears and the script has loaded (from GitHub, or bundled fallback if GitHub is blocked).
+2. (Optional) Click **Check latest Code.gs** to see if the bundled copy matches the latest file on GitHub.
+3. Click **Open Apps Script** if you need a new project tab.
+4. In Apps Script: **New project** (or open an existing project), delete the default `Code.gs` content.
+5. Back in the extension, click **Copy Code.gs** (or **Download Code.gs** and open the file).
+6. Paste into the Apps Script editor and **Save**.
+
+Deploy as a **Web app** (see the main README Quick Start): **Execute as: Me**, **Who has access: Anyone** (unless your setup requires something else). Finish authorization if Google asks.
+
+Copy the **Deployment ID** Google shows you (you will paste it in Step 3 of the extension).
+
+---
+
+## 6. Build your local config snippet
+
+In **Step 3**:
+
+1. Paste the **Deployment ID** into the **Deployment ID** field.
+2. Click **Copy config snippet**.
+3. Merge the JSON into your **`config.json`** for `mhrv-rs` (or paste into the app’s config UI if you use the graphical mode).
+
+Typical shape:
+
+```json
+{
+ "mode": "apps_script",
+ "script_id": "YOUR_DEPLOYMENT_ID",
+ "auth_key": "YOUR_AUTH_KEY",
+ "listen_port": 8085
+}
+```
+
+The extension fills `script_id` and `auth_key` from what you entered and generated.
+
+---
+
+## 7. Run mhrv-rs and point the browser at the proxy
+
+Follow the **main project README** from “First run” onward: save config, start the proxy, set Firefox or Chrome to `127.0.0.1:8085` (or use SwitchyOmega as documented there).
+
+Use **View app docs** / **Open setup guide** in the extension footer for links to the official README and guide.
+
+---
+
+## Troubleshooting (short)
+
+| Problem | What to try |
+|--------|-------------|
+| Script never loads | GitHub raw may be blocked; the extension falls back to bundled `Code.gs`. Use **Check latest Code.gs** after the network improves. |
+| Copy buttons do nothing | Some contexts block clipboard; try focusing the popup first or use **Download Code.gs** instead. |
+| Wrong binary downloaded | Use **View all releases** and pick the archive for your OS manually. |
+
+For deeper issues (CAPTCHA, Telegram, full tunnel), use [docs/guide.md](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/docs/guide.md) in the main repo.
\ No newline at end of file
diff --git a/chrome-extension/README.fa.md b/chrome-extension/README.fa.md
new file mode 100644
index 00000000..8c7001b8
--- /dev/null
+++ b/chrome-extension/README.fa.md
@@ -0,0 +1,88 @@
+# کمککنندهٔ Apps Script برای mhrv-rs (افزونهٔ کروم)
+
+[English](README.md) | فارسی
+
+این یک **افزونهٔ سبک کروم (Manifest V3)** است که راهاندازی اولیهٔ **Google Apps Script** را برای استفاده از **[mhrv-rs](https://github.com/therealaleph/MasterHttpRelayVPN-RUST)** در حالت **رلهٔ Apps Script** سادهتر میکند. خود پروکسی یا تونل نیست؛ فقط در تولید کلید، گرفتن `Code.gs` و ساختن قطعهٔ `config.json` کمک میکند.
+
+**نسخهٔ پیشنهادی برای کلون و توسعه:** [ardalan-ab/mhrv-helper-extension](https://github.com/ardalan-ab/mhrv-helper-extension)
+**پروژهٔ اصلی:** [therealaleph/MasterHttpRelayVPN-RUST](https://github.com/therealaleph/MasterHttpRelayVPN-RUST)
+
+**راهنمای گامبهگام برای کاربر:** [HOW_TO_USE.fa.md](HOW_TO_USE.fa.md) · [English](HOW_TO_USE.md)
+
+---
+
+## این افزونه چه کار میکند؟
+
+| مرحله | خروجی |
+| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| ۰ | باز کردن آخرین نسخهٔ **mhrv-rs** متناسب با سیستمعامل شما (از طریق API گیتهاب). |
+| ۱ | تولید **AUTH_KEY** تصادفی و قوی داخل مرورگر (`crypto.getRandomValues`). |
+| ۲ | بارگذاری **Code.gs** از `raw.githubusercontent.com` (مسیر رسمی در مخزن اصلی)، با **نسخهٔ پشتیبان داخل بسته** اگر گیتهاب در دسترس نباشد. دکمهٔ **بررسی آخرین نسخه Code.gs** وضعیت همخوانی با upstream را نشان میدهد. |
+| ۳ | ساخت **قطعهٔ JSON پیکربندی** (حالت `apps_script`) پس از وارد کردن **شناسهٔ استقرار (Deployment ID)**. |
+
+رابط کاربری به **انگلیسی** و **فارسی** است و برای فارسی چیدمان **راستبهچپ (RTL)** درست شده است.
+
+---
+
+## مجوزها (چرا لازماند)
+
+- **`storage`**: برای تنظیمات احتمالی آینده؛ در جریان فعلی اصلی افزونه الزامی نیست.
+- **`clipboardWrite`**: کپی کردن کلید، `Code.gs` و قطعهٔ config با کلیک روی دکمههای کپی.
+- **دسترسی به میزبانها:** `script.google.com` (باز کردن Apps Script)، `raw.githubusercontent.com` (گرفتن `Code.gs`)، `api.github.com` (پیدا کردن آخرین ریلیز برای دانلود).
+
+**AUTH_KEY** فقط روی دستگاه شما ساخته میشود و توسط این افزونه به هیچ سروری ارسال نمیشود؛ تستهای خودکار (Playwright) بررسی میکنند که در ترافیک شبکهٔ ثبتشده و storage افزونه دیده نشود.
+
+---
+
+## نصب (بارگذاری unpacked)
+
+[اینجا کلیک کنید](HOW_TO_USE.fa.md)
+
+## توسعه و تست
+
+### بررسی سریع syntax
+
+```bash
+python3 -m json.tool manifest.json
+python3 -m json.tool messages.json
+node --check popup.js
+```
+
+### تست دستی UI (بدون APIهای کروم)
+
+از همین پوشه:
+
+```bash
+python3 -m http.server 8000
+```
+
+در مرورگر `http://localhost:8000/test.html` را باز کنید (رفتار کلیپبورد و `chrome.*` با افزونهٔ واقعی فرق دارد).
+
+### تست خودکار smoke (Playwright)
+
+```bash
+npm install
+npx playwright install chromium
+npm run test:smoke
+```
+
+روی لینوکس بدون نمایشگر: `xvfb-run npm run test:smoke` (مثل CI).
+
+### CI — همخوانی `Code.gs` با upstream
+
+- **مخزن فقط افزونه** (ریشهٔ git همین پوشه است): workflow [`.github/workflows/sync-codegs.yml`](.github/workflows/sync-codegs.yml) فایل canonical را از `main` پروژهٔ `therealaleph/MasterHttpRelayVPN-RUST` میگیرد و با `Code.gs` داخل بسته مقایسه میکند.
+- **داخل مونوریپوی کامل**: workflow ریشهٔ مخزن [`.github/workflows/chrome-extension.yml`](../../.github/workflows/chrome-extension.yml) `chrome-extension/Code.gs` را با `assets/apps_script/Code.gs` همان commit مقایسه میکند.
+
+دکمهٔ **بررسی آخرین نسخه Code.gs** از **GitHub Contents API** برای همان مسیر استفاده میکند، بعد محتوا را با بسته مقایسه میکند و نتیجهٔ API (blob کوتاه، اندازه، یا متن خطای API) را نشان میدهد.
+
+---
+
+## نسخه
+
+مقدار `version` در **`manifest.json`** (مثلاً **0.2.0**).
+
+---
+
+## مجوز upstream
+
+رفتار و محتوای `Code.gs` توسط پروژهٔ **MasterHttpRelayVPN-RUST** تعیین میشود. انتشار در **Chrome Web Store** باید با قوانین گوگل و مجوزهای مخزن اصلی سازگار باشد.
diff --git a/chrome-extension/README.md b/chrome-extension/README.md
new file mode 100644
index 00000000..da6f96ef
--- /dev/null
+++ b/chrome-extension/README.md
@@ -0,0 +1,94 @@
+# mhrv-rs Apps Script Helper (Chrome extension)
+
+English | [فارسی](README.fa.md)
+
+A small **Chrome extension (Manifest V3)** that speeds up **Google Apps Script** setup for **[mhrv-rs](https://github.com/therealaleph/MasterHttpRelayVPN-RUST)** when you use **Apps Script relay mode**. It does not replace the proxy or tunnel; it only helps you generate secrets, pull the canonical `Code.gs`, and build a local `config.json` snippet.
+
+**Maintainer / standalone source:** [ardalan-ab/mhrv-helper-extension](https://github.com/ardalan-ab/mhrv-helper-extension) (recommended).
+**Upstream project:** [therealaleph/MasterHttpRelayVPN-RUST](https://github.com/therealaleph/MasterHttpRelayVPN-RUST).
+
+**Step-by-step for users:** [HOW_TO_USE.md](HOW_TO_USE.md) · [راهنمای فارسی](HOW_TO_USE.fa.md)
+
+---
+
+## What it does
+
+| Step | What you get |
+| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 0 | Opens the latest **mhrv-rs** release asset for your OS (via GitHub API). |
+| 1 | Generates a strong random **AUTH_KEY** in the browser (`crypto.getRandomValues`). |
+| 2 | Loads **Code.gs** from `raw.githubusercontent.com` (canonical path in the main repo), with a **bundled fallback** if the network blocks GitHub. Optional **Check latest Code.gs** compares upstream vs bundled. |
+| 3 | Builds a **JSON config snippet** (`apps_script` mode) once you paste your **Deployment ID**. |
+
+The popup is available in **English** and **Persian (فارسی)** with **RTL** layout for Persian.
+
+---
+
+## Permissions (why they exist)
+
+- **`storage`**: reserved for future settings; the extension does not need it for core flow today.
+- **`clipboardWrite`**: copy auth key, `Code.gs`, and config snippet to the clipboard when you click the copy buttons.
+- **Host access**: `script.google.com` (open Apps Script), `raw.githubusercontent.com` (fetch `Code.gs`), `api.github.com` (resolve latest release for downloads).
+
+The **AUTH_KEY** is generated locally. It is **not** sent to any server by this extension; automated smoke tests assert it does not appear in captured network traffic or extension storage during the test run.
+
+---
+
+## Install (load unpacked)
+
+[click here](HOW_TO_USE.md)
+
+Use **View app docs** / **Open setup guide** in the popup for links to the main repository README and guide.
+
+---
+
+## Development and testing
+
+### JSON and script sanity (local)
+
+```bash
+python3 -m json.tool manifest.json
+python3 -m json.tool messages.json
+node --check popup.js
+```
+
+### Manual UI (no Chrome APIs)
+
+From this folder:
+
+```bash
+python3 -m http.server 8000
+```
+
+Open `http://localhost:8000/test.html` in a normal tab (clipboard and `chrome.*` APIs differ from the real extension).
+
+### Automated smoke test (Playwright)
+
+Requires Node.js and a one-time browser install for Playwright:
+
+```bash
+npm install
+npx playwright install chromium
+npm run test:smoke
+```
+
+On Linux without a display, use `xvfb-run npm run test:smoke` (as in CI).
+
+### CI — bundled `Code.gs` matches upstream main
+
+- **Standalone extension repo** (this folder is the git root): workflow [`.github/workflows/sync-codegs.yml`](.github/workflows/sync-codegs.yml) downloads `assets/apps_script/Code.gs` from `therealaleph/MasterHttpRelayVPN-RUST` `main` and `cmp`s it to the bundled `Code.gs`.
+- **Inside the full monorepo**: the root workflow [`.github/workflows/chrome-extension.yml`](../../.github/workflows/chrome-extension.yml) compares `chrome-extension/Code.gs` with `assets/apps_script/Code.gs` on the same commit.
+
+The popup **Check latest Code.gs** button calls the **GitHub Contents API** for that file, then compares bytes to your bundle and shows the API result (blob short SHA, size, or the API error `message`).
+
+---
+
+## Version
+
+See **`manifest.json`** → `version` (currently aligned with extension releases, e.g. **0.2.0**).
+
+---
+
+## License and upstream
+
+Behavior and `Code.gs` content are defined by the **MasterHttpRelayVPN-RUST** project. Use and distribute this helper in line with the licenses and policies of the upstream repository and the Chrome Web Store if you publish there.
diff --git a/chrome-extension/_locales/en/messages.json b/chrome-extension/_locales/en/messages.json
new file mode 100644
index 00000000..0b3c14ea
--- /dev/null
+++ b/chrome-extension/_locales/en/messages.json
@@ -0,0 +1,8 @@
+{
+ "extName": {
+ "message": "mhrv-rs Apps Script Helper"
+ },
+ "extDescription": {
+ "message": "Generate Google Apps Script deployment code and configuration for mhrv-rs."
+ }
+}
diff --git a/chrome-extension/_locales/fa/messages.json b/chrome-extension/_locales/fa/messages.json
new file mode 100644
index 00000000..65847b53
--- /dev/null
+++ b/chrome-extension/_locales/fa/messages.json
@@ -0,0 +1,8 @@
+{
+ "extName": {
+ "message": "کمککننده اسکریپت اپس mhrv-rs"
+ },
+ "extDescription": {
+ "message": "تولید کد استقرار Google Apps Script و پیکربندی برای mhrv-rs."
+ }
+}
diff --git a/chrome-extension/assets/logo.webp b/chrome-extension/assets/logo.webp
new file mode 100644
index 00000000..75e3ceab
Binary files /dev/null and b/chrome-extension/assets/logo.webp differ
diff --git a/chrome-extension/background.js b/chrome-extension/background.js
new file mode 100644
index 00000000..aea59063
--- /dev/null
+++ b/chrome-extension/background.js
@@ -0,0 +1,2 @@
+// Intentionally empty service worker.
+// Keeps extension runtime discoverable for automated smoke tests.
diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json
new file mode 100644
index 00000000..07ebb20b
--- /dev/null
+++ b/chrome-extension/manifest.json
@@ -0,0 +1,16 @@
+{
+ "manifest_version": 3,
+ "name": "__MSG_extName__",
+ "description": "__MSG_extDescription__",
+ "version": "0.2.0",
+ "default_locale": "en",
+ "action": {
+ "default_title": "__MSG_extName__",
+ "default_popup": "popup.html"
+ },
+ "background": {
+ "service_worker": "background.js"
+ },
+ "permissions": ["storage", "clipboardWrite"],
+ "host_permissions": ["https://script.google.com/*", "https://raw.githubusercontent.com/*", "https://api.github.com/*"]
+}
diff --git a/chrome-extension/messages.json b/chrome-extension/messages.json
new file mode 100644
index 00000000..962cc337
--- /dev/null
+++ b/chrome-extension/messages.json
@@ -0,0 +1,92 @@
+{
+ "en": {
+ "appName": "mhrv-rs Apps Script Helper",
+ "appDesc": "Generate Google Apps Script deployment code and configuration for mhrv-rs.",
+ "step0Title": "Step 0: Download mhrv-rs",
+ "step0Desc": "Get the latest Rust binary for your platform.",
+ "downloadRust": "Download mhrv-rs",
+ "viewReleases": "View all releases",
+ "step1Title": "Step 1: Generate a strong auth key",
+ "step1Desc": "This secret is used by the Apps Script and your local config.",
+ "generateKey": "Generate auth key",
+ "copyKey": "Copy key",
+ "step2Title": "Step 2: Build your Apps Script",
+ "step2Desc": "Open Apps Script, paste the generated code, and deploy as a Web app.",
+ "copyScript": "Copy Code.gs",
+ "downloadScript": "Download Code.gs",
+ "openScript": "Open Apps Script",
+ "scriptNotice": "The script is loaded from the extension package and includes the same relay protocol used by mhrv-rs.",
+ "checkScriptVersion": "Check latest Code.gs",
+ "step3Title": "Step 3: Create config",
+ "step3Desc": "Paste your Deployment ID and the auth key into your local config.",
+ "deploymentIdLabel": "Deployment ID",
+ "deploymentIdPlaceholder": "YOUR_APPS_SCRIPT_DEPLOYMENT_ID",
+ "copyConfig": "Copy config snippet",
+ "linksTitle": "Quick links",
+ "viewDocs": "View app docs",
+ "setupGuide": "Open setup guide",
+ "proxyNote": "Use a local browser proxy such as Proxy SwitchyOmega for Chrome/Edge.",
+ "keyGenerated": "Auth key generated. Paste it into Apps Script and config.",
+ "copied": "Copied {item}.",
+ "scriptDownloaded": "Downloaded Code.gs for Apps Script deployment.",
+ "codeLoaded": "Code.gs loaded from repository.",
+ "codeLoadedFallback": "Failed to load Code.gs from repository. Using local fallback.",
+ "downloadError": "Failed to fetch latest release. Opening releases page.",
+ "downloadSuccess": "Opening download page for latest mhrv-rs binary.",
+ "scriptNotLoaded": "Script template not loaded yet.",
+ "generateKeyFirst": "Generate an auth key first.",
+ "copyError": "Could not copy {item}.",
+ "fetchError": "Failed to load Code.gs at all."
+ ,
+ "scriptUpToDateDetail": "Bundled Code.gs matches main. GitHub API: blob {sha}… ({size} bytes).",
+ "scriptOutdatedDetail": "Bundled Code.gs differs from main. Upstream blob {sha}… ({size} bytes). Update bundled Code.gs from the main repo.",
+ "scriptCheckApiError": "GitHub API ({status}): {detail}",
+ "scriptCheckRawFailed": "GitHub API OK but file body missing: {detail}",
+ "scriptCheckFailed": "Could not verify Code.gs version."
+ },
+ "fa": {
+ "appName": "کمککننده اسکریپت اپس mhrv-rs",
+ "appDesc": "تولید کد استقرار Google Apps Script و پیکربندی برای mhrv-rs.",
+ "step0Title": "مرحله ۰: دانلود mhrv-rs",
+ "step0Desc": "دریافت آخرین باینری Rust برای پلتفرم شما.",
+ "downloadRust": "دانلود mhrv-rs",
+ "viewReleases": "مشاهده همه انتشارها",
+ "step1Title": "مرحله ۱: تولید کلید احراز هویت قوی",
+ "step1Desc": "این راز توسط Apps Script و پیکربندی محلی شما استفاده میشود.",
+ "generateKey": "تولید کلید احراز هویت",
+ "copyKey": "کپی کلید",
+ "step2Title": "مرحله ۲: ساخت Apps Script شما",
+ "step2Desc": "Apps Script را باز کنید، کد تولید شده را جایگذاری کنید و به عنوان Web app مستقر کنید.",
+ "copyScript": "کپی Code.gs",
+ "downloadScript": "دانلود Code.gs",
+ "openScript": "باز کردن Apps Script",
+ "scriptNotice": "اسکریپت از بسته افزونه بارگذاری میشود و شامل همان پروتکل رله استفاده شده توسط mhrv-rs است.",
+ "checkScriptVersion": "بررسی آخرین نسخه Code.gs",
+ "step3Title": "مرحله ۳: ایجاد پیکربندی",
+ "step3Desc": "شناسه استقرار و کلید احراز هویت را در پیکربندی محلی خود جایگذاری کنید.",
+ "deploymentIdLabel": "شناسه استقرار",
+ "deploymentIdPlaceholder": "شناسه_استقرار_اپس_اسکریپت_شما",
+ "copyConfig": "کپی قطعه پیکربندی",
+ "linksTitle": "پیوندهای سریع",
+ "viewDocs": "مشاهده مستندات برنامه",
+ "setupGuide": "باز کردن راهنمای راهاندازی",
+ "proxyNote": "از یک پروکسی مرورگر محلی مانند Proxy SwitchyOmega برای Chrome/Edge استفاده کنید.",
+ "keyGenerated": "کلید احراز هویت تولید شد. آن را در Apps Script و پیکربندی جایگذاری کنید.",
+ "copied": "{item} کپی شد.",
+ "scriptDownloaded": "Code.gs برای استقرار Apps Script دانلود شد.",
+ "codeLoaded": "Code.gs از مخزن بارگذاری شد.",
+ "codeLoadedFallback": "بارگذاری Code.gs از مخزن ناموفق بود. از نسخه محلی استفاده میشود.",
+ "downloadError": "دریافت آخرین انتشار ناموفق بود. صفحه انتشارها باز میشود.",
+ "downloadSuccess": "صفحه دانلود برای آخرین باینری mhrv-rs باز میشود.",
+ "scriptNotLoaded": "الگوی اسکریپت هنوز بارگذاری نشده است.",
+ "generateKeyFirst": "ابتدا یک کلید احراز هویت تولید کنید.",
+ "copyError": "کپی {item} امکانپذیر نبود.",
+ "fetchError": "بارگذاری Code.gs به طور کامل ناموفق بود."
+ ,
+ "scriptUpToDateDetail": "Code.gs داخل افزونه با main یکی است. API گیتهاب: blob {sha}… ({size} بایت).",
+ "scriptOutdatedDetail": "Code.gs داخل افزونه با main فرق دارد. blob upstream: {sha}… ({size} بایت). فایل را از مخزن اصلی بهروز کن.",
+ "scriptCheckApiError": "خطای API گیتهاب ({status}): {detail}",
+ "scriptCheckRawFailed": "API گیتهاب OK بود اما بدنهٔ فایل نیامد: {detail}",
+ "scriptCheckFailed": "امکان بررسی نسخه Code.gs وجود ندارد."
+ }
+}
\ No newline at end of file
diff --git a/chrome-extension/package-lock.json b/chrome-extension/package-lock.json
new file mode 100644
index 00000000..429311db
--- /dev/null
+++ b/chrome-extension/package-lock.json
@@ -0,0 +1,76 @@
+{
+ "name": "mhrv-helper-extension-tests",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "mhrv-helper-extension-tests",
+ "devDependencies": {
+ "@playwright/test": "latest"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
+ "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.60.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
+ "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.60.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
+ "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/chrome-extension/package.json b/chrome-extension/package.json
new file mode 100644
index 00000000..aba0ca44
--- /dev/null
+++ b/chrome-extension/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "mhrv-helper-extension-tests",
+ "private": true,
+ "scripts": {
+ "test:smoke": "playwright test --config=playwright.config.js"
+ },
+ "devDependencies": {
+ "@playwright/test": "latest"
+ }
+}
diff --git a/chrome-extension/playwright.config.js b/chrome-extension/playwright.config.js
new file mode 100644
index 00000000..710942a8
--- /dev/null
+++ b/chrome-extension/playwright.config.js
@@ -0,0 +1,27 @@
+const { defineConfig } = require('@playwright/test');
+const path = require('path');
+
+module.exports = defineConfig({
+ testDir: './tests',
+ timeout: 60_000,
+ fullyParallel: false,
+ retries: 0,
+ reporter: 'list',
+ use: {
+ headless: false,
+ },
+ projects: [
+ {
+ name: 'chromium-extension',
+ use: {
+ channel: 'chromium',
+ launchOptions: {
+ args: [
+ `--disable-extensions-except=${path.resolve(__dirname)}`,
+ `--load-extension=${path.resolve(__dirname)}`,
+ ],
+ },
+ },
+ },
+ ],
+});
diff --git a/chrome-extension/popup.css b/chrome-extension/popup.css
new file mode 100644
index 00000000..4c5dbb2a
--- /dev/null
+++ b/chrome-extension/popup.css
@@ -0,0 +1,288 @@
+:root {
+ color-scheme: light;
+ font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
+ font-size: 13px;
+ color: #111;
+ background: #f7fafc;
+ --primary: #2563eb;
+ --primary-hover: #1d4ed8;
+ --secondary: #64748b;
+ --secondary-hover: #475569;
+ --success: #059669;
+ --warning: #d97706;
+ --error: #dc2626;
+ --border: #e2e8f0;
+ --bg: #ffffff;
+ --bg-secondary: #f8fafc;
+}
+
+body {
+ margin: 0;
+ min-width: 380px;
+ max-width: 420px;
+}
+
+body[dir="rtl"] {
+ direction: rtl;
+}
+
+.container {
+ padding: 20px;
+}
+
+header {
+ margin-bottom: 24px;
+ text-align: center;
+}
+
+.header-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+header h1 {
+ margin: 0;
+ font-size: 1.1rem;
+ font-weight: 700;
+ color: var(--primary);
+}
+
+header p {
+ margin: 0;
+ color: #64748b;
+ font-size: 0.9rem;
+}
+
+#language-select {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 6px 10px;
+ font-size: 0.85rem;
+ color: var(--secondary);
+ cursor: pointer;
+ transition: border-color 0.2s ease;
+}
+
+#language-select:focus {
+ outline: none;
+ border-color: var(--primary);
+}
+
+.step {
+ margin-bottom: 20px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ padding: 18px;
+ position: relative;
+ transition: box-shadow 0.2s ease;
+}
+
+.step:hover {
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+
+.step h2 {
+ margin: 0 0 10px;
+ font-size: 1rem;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.step h2::before {
+ content: '';
+ width: 6px;
+ height: 6px;
+ background: var(--primary);
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.step.note h2::before {
+ background: var(--secondary);
+}
+
+.step p {
+ margin: 0 0 14px;
+ line-height: 1.5;
+ color: #64748b;
+}
+
+.row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-bottom: 14px;
+}
+
+.row.small {
+ gap: 6px;
+}
+
+button {
+ border: none;
+ border-radius: 12px;
+ padding: 12px 16px;
+ font-size: 0.9rem;
+ font-weight: 500;
+ cursor: pointer;
+ background: var(--primary);
+ color: #fff;
+ transition: all 0.2s ease;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ justify-content: center;
+}
+
+button::before {
+ content: '';
+ width: 16px;
+ height: 16px;
+ background-size: contain;
+ background-repeat: no-repeat;
+ background-position: center;
+ flex-shrink: 0;
+}
+
+button#generate-key::before {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='currentColor'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z'/%3E%3C/svg%3E");
+}
+
+button#copy-key::before,
+button#copy-script::before,
+button#copy-config::before {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='currentColor'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z'/%3E%3C/svg%3E");
+}
+
+button#download-script::before,
+button#download-rust::before {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='currentColor'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'/%3E%3C/svg%3E");
+}
+
+button#open-script::before,
+button#open-readme::before,
+button#open-guide::before,
+button#open-releases::before {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='currentColor'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14'/%3E%3C/svg%3E");
+}
+
+button#check-script-version::before {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='currentColor'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z'/%3E%3C/svg%3E");
+}
+
+button.secondary {
+ background: var(--bg-secondary);
+ color: var(--secondary);
+ border: 1px solid var(--border);
+}
+
+button.secondary::before {
+ filter: invert(0.5);
+}
+
+button:hover {
+ background: var(--primary-hover);
+ transform: translateY(-1px);
+}
+
+button.secondary:hover {
+ background: var(--secondary-hover);
+ color: white;
+}
+
+button:active {
+ transform: translateY(0);
+}
+
+label {
+ display: block;
+ margin-bottom: 12px;
+ color: #374151;
+ font-weight: 500;
+}
+
+input,
+textarea {
+ width: 100%;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 12px 14px;
+ font-family: inherit;
+ font-size: 0.95rem;
+ background: var(--bg-secondary);
+ color: #0f172a;
+ resize: vertical;
+ transition: border-color 0.2s ease;
+}
+
+input:focus,
+textarea:focus {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
+}
+
+textarea {
+ min-height: 100px;
+ max-height: 240px;
+ overflow: auto;
+}
+
+.notice {
+ background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
+ border-left: 4px solid var(--primary);
+ padding: 12px 16px;
+ color: #0c4a6e;
+ font-size: 0.9rem;
+ border-radius: 0 12px 12px 12px;
+ margin-top: 12px;
+}
+
+footer {
+ margin-top: 16px;
+ padding-top: 8px;
+ text-align: center;
+}
+
+#message {
+ color: var(--success);
+ font-size: 0.9rem;
+ font-weight: 500;
+ padding: 8px 12px;
+ border-radius: 8px;
+ background: rgba(5, 150, 105, 0.1);
+ display: inline-block;
+ max-width: 100%;
+ box-sizing: border-box;
+ word-break: break-word;
+ text-align: start;
+}
+
+#message.error {
+ color: var(--error);
+ background: rgba(220, 38, 38, 0.1);
+}
+
+.progress {
+ position: absolute;
+ top: 18px;
+ right: 18px;
+ width: 20px;
+ height: 20px;
+ border: 2px solid var(--border);
+ border-top: 2px solid var(--primary);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ display: none;
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
diff --git a/chrome-extension/popup.html b/chrome-extension/popup.html
new file mode 100644
index 00000000..3ecb9419
--- /dev/null
+++ b/chrome-extension/popup.html
@@ -0,0 +1,80 @@
+
+
+
+
+
+ mhrv-rs Apps Script Helper
+
+
+
+
+
+
+
mhrv-rs Apps Script Helper
+
+
+
Generate Google Apps Script deployment code and configuration for mhrv-rs.
+
+
+
+
Step 0: Download mhrv-rs
+
Get the latest Rust binary for your platform.
+
+
+
+
+
+
+
+
Step 1: Generate a strong auth key
+
This secret is used by the Apps Script and your local config.
+
+
+
+
+
+
+
+
+
Step 2: Build your Apps Script
+
Open Apps Script, paste the generated code, and deploy as a Web app.
+
+
+
+
+
+
+
+
The script is loaded from the extension package and includes the same relay protocol used by mhrv-rs.
+
+
+
+
Step 3: Create config
+
Paste your Deployment ID and the auth key into your local config.
+
+
+
+
+
+
+
+
+
+
Quick links
+
+
+
+
+
Use a local browser proxy such as Proxy SwitchyOmega for Chrome/Edge.