From 1e1f6cb3e553729558d663c51aa09bed91fa40b2 Mon Sep 17 00:00:00 2001 From: "a.abdollahian" Date: Mon, 11 May 2026 14:08:58 +0330 Subject: [PATCH 1/9] feat: add Chrome extension helper for Apps Script setup - Generates strong AUTH_KEY - Prepares Code.gs with relay protocol - Builds config.json snippet - Links to docs and Apps Script editor - Automates manual setup steps --- README.md | 2 + chrome-extension/Code.gs | 783 +++++++++++++++++++++++++++++++++ chrome-extension/README.md | 47 ++ chrome-extension/manifest.json | 12 + chrome-extension/popup.css | 120 +++++ chrome-extension/popup.html | 65 +++ chrome-extension/popup.js | 173 ++++++++ 7 files changed, 1202 insertions(+) create mode 100644 chrome-extension/Code.gs create mode 100644 chrome-extension/README.md create mode 100644 chrome-extension/manifest.json create mode 100644 chrome-extension/popup.css create mode 100644 chrome-extension/popup.html create mode 100644 chrome-extension/popup.js diff --git a/README.md b/README.md index cd065e0..f7e7beb 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,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 `chrome-extension/README.md` for a browser helper that generates `Code.gs` and local config automatically. ### Step 2 — Download mhrv-rs diff --git a/chrome-extension/Code.gs b/chrome-extension/Code.gs new file mode 100644 index 0000000..c90f829 --- /dev/null +++ b/chrome-extension/Code.gs @@ -0,0 +1,783 @@ +/** + * 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) ──────── + var opts = _buildOpts(req); + var resp = UrlFetchApp.fetch(req.u, opts); + return _json({ + s: resp.getResponseCode(), + h: _respHeaders(resp), + b: Utilities.base64Encode(resp.getContent()), + }); +} + +// ── 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. + */ +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/README.md b/chrome-extension/README.md new file mode 100644 index 0000000..c7aa7ad --- /dev/null +++ b/chrome-extension/README.md @@ -0,0 +1,47 @@ +# mhrv-rs Apps Script Helper Chrome Extension + +This Chrome extension is a lightweight helper for the `MasterHttpRelayVPN-RUST` project. It automates the first-time Apps Script setup by generating a strong `AUTH_KEY`, preparing the `Code.gs` source, and producing a local config snippet. + +## What it does + +- Generates a strong random `AUTH_KEY` +- Creates a ready-to-deploy `Code.gs` file with the same relay protocol used by the repo +- Opens Google Apps Script in a new tab +- Builds a JSON config snippet for `config.json` +- Links to the repo documentation for setup and troubleshooting + +## Installation + +1. Open Chrome and go to `chrome://extensions`. +2. Enable **Developer mode**. +3. Click **Load unpacked**. +4. Select the `chrome-extension` folder in this repository. +5. The extension icon should appear in your toolbar. + +## Usage + +1. Click the extension icon. +2. Tap **Generate auth key**. +3. Tap **Copy Code.gs** or **Download Code.gs**. +4. In `https://script.google.com`, create a new Apps Script project and paste the generated contents. +5. Deploy as a Web App with: + - **Execute as:** Me + - **Who has access:** Anyone +6. Copy the deployment ID and paste it into the Deployment ID field in the extension. +7. Tap **Copy config snippet** and paste the result into your local `config.json`. + +## Notes + +- The extension does not deploy Apps Script automatically; it only generates the code and configuration. +- The extension stores no secret values persistently in Chrome storage. +- If your network does not allow `script.google.com`, use the project in `direct` mode first and then follow the guide. + +## Recommended workflow + +- Use the extension to avoid manual editing mistakes. +- Keep the generated `AUTH_KEY` secret. +- If you need full tunnel mode later, use the repo docs to deploy `CodeFull.gs` or `Code.cfw.gs`. + +## Limitations + +This helper is intentionally minimal and does not perform OAuth on behalf of your Google account. It simplifies the code generation and setup flow but still requires a manual Apps Script deployment step inside Google. diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json new file mode 100644 index 0000000..8fb4c63 --- /dev/null +++ b/chrome-extension/manifest.json @@ -0,0 +1,12 @@ +{ + "manifest_version": 3, + "name": "mhrv-rs Apps Script Helper", + "description": "Generate Google Apps Script deployment code and configuration for mhrv-rs.", + "version": "0.1.0", + "action": { + "default_title": "mhrv-rs Apps Script Helper", + "default_popup": "popup.html" + }, + "permissions": ["storage", "clipboardWrite"], + "host_permissions": ["https://script.google.com/*"] +} diff --git a/chrome-extension/popup.css b/chrome-extension/popup.css new file mode 100644 index 0000000..ac4d502 --- /dev/null +++ b/chrome-extension/popup.css @@ -0,0 +1,120 @@ +:root { + color-scheme: light; + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 13px; + color: #111; + background: #f7fafc; +} + +body { + margin: 0; + min-width: 360px; +} + +.container { + padding: 16px; +} + +header h1 { + margin: 0 0 4px; + font-size: 1rem; +} + +header p { + margin: 0 0 16px; + color: #475569; + font-size: 0.95rem; +} + +.step { + margin-bottom: 18px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 14px; + padding: 14px; +} + +.step h2 { + margin: 0 0 8px; + font-size: 0.95rem; +} + +.step p { + margin: 0 0 12px; + line-height: 1.5; +} + +.row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} + +.row.small { + gap: 4px; +} + +button { + border: none; + border-radius: 10px; + padding: 10px 14px; + font-size: 0.92rem; + cursor: pointer; + background: #0f172a; + color: #fff; + transition: transform 0.1s ease, background 0.2s ease; +} + +button.secondary { + background: #e2e8f0; + color: #111827; +} + +button:hover { + transform: translateY(-1px); +} + +label { + display: block; + margin-bottom: 10px; + color: #334155; +} + +input, +textarea { + width: 100%; + border: 1px solid #cbd5e1; + border-radius: 10px; + padding: 10px 12px; + font-family: inherit; + font-size: 0.95rem; + background: #f8fafc; + color: #0f172a; + resize: vertical; +} + +textarea { + min-height: 88px; + max-height: 220px; + overflow: auto; +} + +.notice { + background: #f8fafc; + border-left: 4px solid #60a5fa; + padding: 10px 12px; + color: #334155; + font-size: 0.92rem; + border-radius: 0 0 10px 10px; +} + +footer { + margin-top: 0; + padding-top: 4px; +} + +#message { + color: #0f172a; + font-size: 0.9rem; +} diff --git a/chrome-extension/popup.html b/chrome-extension/popup.html new file mode 100644 index 0000000..1ff4ae7 --- /dev/null +++ b/chrome-extension/popup.html @@ -0,0 +1,65 @@ + + + + + + mhrv-rs Apps Script Helper + + + +
+
+

mhrv-rs Apps Script Helper

+

Generate Google Apps Script code and config for mhrv-rs.

+
+ +
+

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.

+
+ +
+ +
+
+ + + diff --git a/chrome-extension/popup.js b/chrome-extension/popup.js new file mode 100644 index 0000000..ad4346a --- /dev/null +++ b/chrome-extension/popup.js @@ -0,0 +1,173 @@ +const AUTH_KEY_PLACEHOLDER = 'CHANGE_ME_TO_A_STRONG_SECRET'; +const CODE_FILE = 'Code.gs'; +let codeTemplate = ''; + +const elements = { + authKey: document.getElementById('auth-key'), + deploymentId: document.getElementById('deployment-id'), + configJson: document.getElementById('config-json'), + message: document.getElementById('message'), + generateKey: document.getElementById('generate-key'), + copyKey: document.getElementById('copy-key'), + copyScript: document.getElementById('copy-script'), + downloadScript: document.getElementById('download-script'), + openScript: document.getElementById('open-script'), + copyConfig: document.getElementById('copy-config'), + openReadme: document.getElementById('open-readme'), + openGuide: document.getElementById('open-guide'), +}; + +function randomHex(length = 32) { + const array = new Uint8Array(length); + crypto.getRandomValues(array); + return Array.from(array) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +} + +function showMessage(text, isError = false) { + elements.message.textContent = text; + elements.message.style.color = isError ? '#b91c1c' : '#0f172a'; +} + +function renderConfig() { + const authKey = elements.authKey.value.trim(); + const deploymentId = elements.deploymentId.value.trim(); + const config = { + mode: 'apps_script', + script_id: deploymentId || 'YOUR_APPS_SCRIPT_DEPLOYMENT_ID', + auth_key: authKey || 'PASTE_YOUR_AUTH_KEY_HERE', + listen_port: 8085, + }; + elements.configJson.value = JSON.stringify(config, null, 2); +} + +function renderScript() { + if (!codeTemplate) { + return; + } + const authKey = elements.authKey.value.trim() || AUTH_KEY_PLACEHOLDER; + const replaced = codeTemplate.replace( + /const\s+AUTH_KEY\s*=\s*"[^"]*";/, + `const AUTH_KEY = "${authKey}";` + ); + document.getElementById('code-preview')?.remove(); + const preview = document.createElement('textarea'); + preview.id = 'code-preview'; + preview.readOnly = true; + preview.value = replaced; + preview.style.marginTop = '8px'; + preview.style.height = '220px'; + preview.style.fontFamily = 'monospace'; + preview.style.whiteSpace = 'pre'; + preview.style.overflow = 'auto'; + preview.style.width = '100%'; + preview.style.border = '1px solid #cbd5e1'; + preview.style.borderRadius = '10px'; + const step = elements.copyScript.closest('.step'); + const existing = step.querySelector('#code-preview'); + if (existing) existing.remove(); + step.appendChild(preview); +} + +function setAuthKey(key) { + elements.authKey.value = key; + renderConfig(); + renderScript(); +} + +async function loadTemplate() { + try { + const response = await fetch(chrome.runtime.getURL(CODE_FILE)); + codeTemplate = await response.text(); + renderScript(); + } catch (err) { + showMessage('Failed to load Code.gs from the extension package.', true); + console.error(err); + } +} + +function copyText(text, label) { + return navigator.clipboard.writeText(text).then( + () => showMessage(`Copied ${label}.`), + (err) => { + console.error(err); + showMessage(`Could not copy ${label}.`, true); + } + ); +} + +function initListeners() { + elements.generateKey.addEventListener('click', () => { + setAuthKey(randomHex(32)); + showMessage('Auth key generated. Paste it into Apps Script and config.'); + }); + + elements.copyKey.addEventListener('click', () => { + const key = elements.authKey.value.trim(); + if (!key) { + showMessage('Generate an auth key first.', true); + return; + } + copyText(key, 'auth key'); + }); + + elements.copyScript.addEventListener('click', () => { + if (!codeTemplate) { + showMessage('Script template not loaded yet.', true); + return; + } + const authKey = elements.authKey.value.trim() || AUTH_KEY_PLACEHOLDER; + const scriptText = codeTemplate.replace( + /const\s+AUTH_KEY\s*=\s*"[^"]*";/, + `const AUTH_KEY = "${authKey}";` + ); + copyText(scriptText, 'Apps Script'); + }); + + elements.downloadScript.addEventListener('click', () => { + if (!codeTemplate) { + showMessage('Script template not loaded yet.', true); + return; + } + const authKey = elements.authKey.value.trim() || AUTH_KEY_PLACEHOLDER; + const scriptText = codeTemplate.replace( + /const\s+AUTH_KEY\s*=\s*"[^"]*";/, + `const AUTH_KEY = "${authKey}";` + ); + const blob = new Blob([scriptText], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'Code.gs'; + anchor.click(); + URL.revokeObjectURL(url); + showMessage('Downloaded Code.gs for Apps Script deployment.'); + }); + + elements.openScript.addEventListener('click', () => { + window.open('https://script.google.com/home/projects', '_blank'); + }); + + elements.copyConfig.addEventListener('click', () => { + copyText(elements.configJson.value, 'config snippet'); + }); + + elements.openReadme.addEventListener('click', () => { + window.open('https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/assets/apps_script/README.md', '_blank'); + }); + + elements.openGuide.addEventListener('click', () => { + window.open('https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/README.md', '_blank'); + }); + + elements.deploymentId.addEventListener('input', renderConfig); +} + +function init() { + loadTemplate(); + initListeners(); + renderConfig(); +} + +init(); From 81aaf2d87004bbc6f2e1a38960eb198bd71dab91 Mon Sep 17 00:00:00 2001 From: "a.abdollahian" Date: Mon, 11 May 2026 14:18:34 +0330 Subject: [PATCH 2/9] feat: make extension fetch Code.gs from repo dynamically - Fetch latest Code.gs from GitHub on load - Fallback to local copy if fetch fails - Update manifest for GitHub access - Improve README with automation details and limitations --- chrome-extension/README.md | 37 ++++++++++++++++++++++++++-------- chrome-extension/manifest.json | 2 +- chrome-extension/popup.js | 14 +++++++++++-- 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/chrome-extension/README.md b/chrome-extension/README.md index c7aa7ad..daa27fe 100644 --- a/chrome-extension/README.md +++ b/chrome-extension/README.md @@ -1,10 +1,11 @@ # mhrv-rs Apps Script Helper Chrome Extension -This Chrome extension is a lightweight helper for the `MasterHttpRelayVPN-RUST` project. It automates the first-time Apps Script setup by generating a strong `AUTH_KEY`, preparing the `Code.gs` source, and producing a local config snippet. +This Chrome extension is a lightweight helper for the `MasterHttpRelayVPN-RUST` project. It automates the first-time Apps Script setup by generating a strong `AUTH_KEY`, fetching the latest `Code.gs` source from the repository, and producing a local config snippet. ## What it does - Generates a strong random `AUTH_KEY` +- Fetches the latest `Code.gs` from the GitHub repository (with local fallback) - Creates a ready-to-deploy `Code.gs` file with the same relay protocol used by the repo - Opens Google Apps Script in a new tab - Builds a JSON config snippet for `config.json` @@ -22,18 +23,38 @@ This Chrome extension is a lightweight helper for the `MasterHttpRelayVPN-RUST` 1. Click the extension icon. 2. Tap **Generate auth key**. -3. Tap **Copy Code.gs** or **Download Code.gs**. -4. In `https://script.google.com`, create a new Apps Script project and paste the generated contents. -5. Deploy as a Web App with: +3. The extension will fetch the latest `Code.gs` from GitHub. +4. Tap **Copy Code.gs** or **Download Code.gs**. +5. In `https://script.google.com`, create a new Apps Script project and paste the generated contents. +6. Deploy as a Web App with: - **Execute as:** Me - **Who has access:** Anyone -6. Copy the deployment ID and paste it into the Deployment ID field in the extension. -7. Tap **Copy config snippet** and paste the result into your local `config.json`. +7. Copy the deployment ID and paste it into the Deployment ID field in the extension. +8. Tap **Copy config snippet** and paste the result into your local `config.json`. + +## Automation Level + +The extension automates as much as possible within Chrome extension limitations: + +- ✅ Generates secure keys +- ✅ Fetches latest script code from repo +- ✅ Prepares deployment-ready code +- ✅ Generates config snippets +- ❌ Cannot automatically deploy to Google Apps Script (requires manual paste and deploy due to OAuth/security restrictions) + +Full automation of Apps Script deployment would require: +- Google OAuth integration +- Apps Script API access +- Publishing as a verified Chrome extension +- User consent for Google account access + +This is beyond the scope of a simple helper extension. ## Notes -- The extension does not deploy Apps Script automatically; it only generates the code and configuration. -- The extension stores no secret values persistently in Chrome storage. +- The extension fetches `Code.gs` from GitHub on load, ensuring you always get the latest version. +- If GitHub is blocked, it falls back to the bundled local copy. +- The extension does not store secret values persistently in Chrome storage. - If your network does not allow `script.google.com`, use the project in `direct` mode first and then follow the guide. ## Recommended workflow diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 8fb4c63..290a6e7 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -8,5 +8,5 @@ "default_popup": "popup.html" }, "permissions": ["storage", "clipboardWrite"], - "host_permissions": ["https://script.google.com/*"] + "host_permissions": ["https://script.google.com/*", "https://raw.githubusercontent.com/*"] } diff --git a/chrome-extension/popup.js b/chrome-extension/popup.js index ad4346a..dc1305e 100644 --- a/chrome-extension/popup.js +++ b/chrome-extension/popup.js @@ -78,12 +78,22 @@ function setAuthKey(key) { async function loadTemplate() { try { - const response = await fetch(chrome.runtime.getURL(CODE_FILE)); + const response = await fetch(CODE_FILE_URL); + if (!response.ok) throw new Error('Failed to fetch Code.gs'); codeTemplate = await response.text(); renderScript(); + showMessage('Code.gs loaded from repository.'); } catch (err) { - showMessage('Failed to load Code.gs from the extension package.', true); + showMessage('Failed to load Code.gs from repository. Using local fallback.', true); console.error(err); + // Fallback to local if fetch fails + try { + const localResponse = await fetch(chrome.runtime.getURL('Code.gs')); + codeTemplate = await localResponse.text(); + renderScript(); + } catch (localErr) { + showMessage('Could not load Code.gs at all.', true); + } } } From d300a91aa06b5666a97a9cb436d5442d24ec8455 Mon Sep 17 00:00:00 2001 From: "a.abdollahian" Date: Mon, 11 May 2026 14:58:05 +0330 Subject: [PATCH 3/9] feat: add mhrv-rs binary download to extension - Add download button for latest mhrv-rs binary - Detect platform and fetch from GitHub releases - Update README with new feature - Extend manifest permissions for GitHub API --- chrome-extension/README.md | 16 +++++++++------- chrome-extension/manifest.json | 2 +- chrome-extension/popup.html | 12 ++++++++---- chrome-extension/popup.js | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 12 deletions(-) diff --git a/chrome-extension/README.md b/chrome-extension/README.md index daa27fe..243e91a 100644 --- a/chrome-extension/README.md +++ b/chrome-extension/README.md @@ -4,6 +4,7 @@ This Chrome extension is a lightweight helper for the `MasterHttpRelayVPN-RUST` ## What it does +- Downloads the latest `mhrv-rs` binary for your platform - Generates a strong random `AUTH_KEY` - Fetches the latest `Code.gs` from the GitHub repository (with local fallback) - Creates a ready-to-deploy `Code.gs` file with the same relay protocol used by the repo @@ -22,15 +23,16 @@ This Chrome extension is a lightweight helper for the `MasterHttpRelayVPN-RUST` ## Usage 1. Click the extension icon. -2. Tap **Generate auth key**. -3. The extension will fetch the latest `Code.gs` from GitHub. -4. Tap **Copy Code.gs** or **Download Code.gs**. -5. In `https://script.google.com`, create a new Apps Script project and paste the generated contents. -6. Deploy as a Web App with: +2. Tap **Download mhrv-rs** to get the latest binary for your platform. +3. Tap **Generate auth key**. +4. The extension will fetch the latest `Code.gs` from GitHub. +5. Tap **Copy Code.gs** or **Download Code.gs**. +6. In `https://script.google.com`, create a new Apps Script project and paste the generated contents. +7. Deploy as a Web App with: - **Execute as:** Me - **Who has access:** Anyone -7. Copy the deployment ID and paste it into the Deployment ID field in the extension. -8. Tap **Copy config snippet** and paste the result into your local `config.json`. +8. Copy the deployment ID and paste it into the Deployment ID field in the extension. +9. Tap **Copy config snippet** and paste the result into your local `config.json`. ## Automation Level diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 290a6e7..516c052 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -8,5 +8,5 @@ "default_popup": "popup.html" }, "permissions": ["storage", "clipboardWrite"], - "host_permissions": ["https://script.google.com/*", "https://raw.githubusercontent.com/*"] + "host_permissions": ["https://script.google.com/*", "https://raw.githubusercontent.com/*", "https://api.github.com/*"] } diff --git a/chrome-extension/popup.html b/chrome-extension/popup.html index 1ff4ae7..ee083f8 100644 --- a/chrome-extension/popup.html +++ b/chrome-extension/popup.html @@ -8,10 +8,14 @@
-
-

mhrv-rs Apps Script Helper

-

Generate Google Apps Script code and config for mhrv-rs.

-
+
+

Step 0: Download mhrv-rs

+

Get the latest Rust binary for your platform.

+
+ + +
+

Step 1: Generate a strong auth key

diff --git a/chrome-extension/popup.js b/chrome-extension/popup.js index dc1305e..8062c96 100644 --- a/chrome-extension/popup.js +++ b/chrome-extension/popup.js @@ -15,6 +15,8 @@ const elements = { copyConfig: document.getElementById('copy-config'), openReadme: document.getElementById('open-readme'), openGuide: document.getElementById('open-guide'), + downloadRust: document.getElementById('download-rust'), + openReleases: document.getElementById('open-releases'), }; function randomHex(length = 32) { @@ -107,6 +109,35 @@ function copyText(text, label) { ); } +async function downloadLatestRust() { + try { + const response = await fetch('https://api.github.com/repos/therealaleph/MasterHttpRelayVPN-RUST/releases/latest'); + if (!response.ok) throw new Error('Failed to fetch releases'); + const release = await response.json(); + const assets = release.assets; + // Detect platform + const platform = navigator.platform.toLowerCase(); + let assetName; + if (platform.includes('win')) { + assetName = assets.find(a => a.name.includes('windows') && a.name.endsWith('.exe')); + } else if (platform.includes('mac')) { + assetName = assets.find(a => a.name.includes('macos') || a.name.includes('darwin')); + } else { + assetName = assets.find(a => a.name.includes('linux')); + } + if (!assetName) { + showMessage('No suitable binary found for your platform.', true); + return; + } + window.open(assetName.browser_download_url, '_blank'); + showMessage('Opening download page for latest mhrv-rs binary.'); + } catch (err) { + console.error(err); + showMessage('Failed to fetch latest release. Opening releases page.', true); + window.open('https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases', '_blank'); + } +} + function initListeners() { elements.generateKey.addEventListener('click', () => { setAuthKey(randomHex(32)); @@ -171,6 +202,9 @@ function initListeners() { window.open('https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/README.md', '_blank'); }); + elements.downloadRust.addEventListener('click', () => downloadLatestRust()); + elements.openReleases.addEventListener('click', () => window.open('https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases', '_blank')); + elements.deploymentId.addEventListener('input', renderConfig); } From d47667fb0a8868abb92c8fafe8c9a49a0b9ed381 Mon Sep 17 00:00:00 2001 From: "a.abdollahian" Date: Tue, 12 May 2026 09:47:15 +0330 Subject: [PATCH 4/9] feat: major extension update v0.2.0 - Add multilingual support (English/Persian) with RTL layout - Modern UI with icons, animations, and improved styling - Loading indicators for better UX feedback - Updated Code.gs to latest version from repo - Better architecture with i18n message system - Enhanced README with new features documentation - Improved error handling and user feedback --- chrome-extension/Code.gs | 44 ++++-- chrome-extension/README.md | 19 ++- chrome-extension/manifest.json | 3 +- chrome-extension/messages.json | 78 +++++++++++ chrome-extension/popup.css | 244 +++++++++++++++++++++++++++------ chrome-extension/popup.html | 62 +++++---- chrome-extension/popup.js | 103 +++++++++++--- 7 files changed, 448 insertions(+), 105 deletions(-) create mode 100644 chrome-extension/messages.json diff --git a/chrome-extension/Code.gs b/chrome-extension/Code.gs index c90f829..1392225 100644 --- a/chrome-extension/Code.gs +++ b/chrome-extension/Code.gs @@ -46,7 +46,7 @@ const AUTH_KEY = "CHANGE_ME_TO_A_STRONG_SECRET"; // (Inspired by #365 Section 3, mhrv-rs v1.8.0+.) const DIAGNOSTIC_MODE = false; -// ── Optional Spreadsheet Cache ──────────────────────────────── +// ── 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"; @@ -91,12 +91,10 @@ const VARY_KEY_HEADERS = ["accept-encoding", "accept-language"]; // `masterking32/MasterHttpRelayVPN@3094288`. const SKIP_HEADERS = { host: 1, connection: 1, "content-length": 1, - "transfer-encoding": 1, "proxy-connection": 1, - "proxy-authorization": 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, + "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. @@ -119,7 +117,7 @@ const DECOY_HTML = '

The script completed but did not return anything.

' + ''; -// ── Request Handlers ───────────────────────────────────────── +// ── Request Handlers ──────────────────────────────────────── function _decoyOrError(jsonBody) { if (DIAGNOSTIC_MODE) return _json(jsonBody); @@ -191,13 +189,27 @@ function _doSingle(req) { } // ── Normal relay (cache disabled or unavailable) ──────── - var opts = _buildOpts(req); - var resp = UrlFetchApp.fetch(req.u, opts); - return _json({ - s: resp.getResponseCode(), - h: _respHeaders(resp), - b: Utilities.base64Encode(resp.getContent()), - }); + // 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 ────────────────────────────────────────── @@ -332,7 +344,7 @@ function _json(obj) { // ═══════════════════════════════════════════════════════════ // SPREADSHEET CACHE — SHEET MANAGEMENT -// ═══════════════════════════════════════════════════════════════════ +// ═══════════════════════════════════════════════════════════ function _initCacheSheet() { if (CACHE_SPREADSHEET_ID === "CHANGE_ME_TO_CACHE_SPREADSHEET_ID") { @@ -430,8 +442,10 @@ function _getHeaderCaseInsensitive(headers, targetKey) { * 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 "<none>" (same semantic). */ function _getCacheKey(url, reqHeaders) { var parts = [url]; diff --git a/chrome-extension/README.md b/chrome-extension/README.md index 243e91a..7a8d897 100644 --- a/chrome-extension/README.md +++ b/chrome-extension/README.md @@ -2,6 +2,14 @@ This Chrome extension is a lightweight helper for the `MasterHttpRelayVPN-RUST` project. It automates the first-time Apps Script setup by generating a strong `AUTH_KEY`, fetching the latest `Code.gs` source from the repository, and producing a local config snippet. +## ✨ New Features (v0.2.0) +- 🌐 **Multilingual Support**: English and Persian (فارسی) interface +- 🎨 **Modern UI**: Improved design with icons, animations, and better UX +- 📱 **RTL Support**: Proper right-to-left layout for Persian +- 🔄 **Loading Indicators**: Visual feedback during script fetching +- 📦 **Auto-Updates**: Always fetches latest Code.gs from repository +- 🏗️ **Better Architecture**: Modular code with i18n support + ## What it does - Downloads the latest `mhrv-rs` binary for your platform @@ -23,11 +31,12 @@ This Chrome extension is a lightweight helper for the `MasterHttpRelayVPN-RUST` ## Usage 1. Click the extension icon. -2. Tap **Download mhrv-rs** to get the latest binary for your platform. -3. Tap **Generate auth key**. -4. The extension will fetch the latest `Code.gs` from GitHub. -5. Tap **Copy Code.gs** or **Download Code.gs**. -6. In `https://script.google.com`, create a new Apps Script project and paste the generated contents. +2. Select your preferred language (English/Persian) from the dropdown. +3. Tap **Download mhrv-rs** to get the latest binary for your platform. +4. Tap **Generate auth key**. +5. The extension will fetch the latest `Code.gs` from GitHub (shows loading indicator). +6. Tap **Copy Code.gs** or **Download Code.gs**. +7. In `https://script.google.com`, create a new Apps Script project and paste the generated contents. 7. Deploy as a Web App with: - **Execute as:** Me - **Who has access:** Anyone diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index 516c052..fc7cc8d 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -2,7 +2,8 @@ "manifest_version": 3, "name": "mhrv-rs Apps Script Helper", "description": "Generate Google Apps Script deployment code and configuration for mhrv-rs.", - "version": "0.1.0", + "version": "0.2.0", + "default_locale": "en", "action": { "default_title": "mhrv-rs Apps Script Helper", "default_popup": "popup.html" diff --git a/chrome-extension/messages.json b/chrome-extension/messages.json new file mode 100644 index 0000000..39f61fc --- /dev/null +++ b/chrome-extension/messages.json @@ -0,0 +1,78 @@ +{ + "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.", + "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." + }, + "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 است.", + "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 به طور کامل ناموفق بود." + } +} \ No newline at end of file diff --git a/chrome-extension/popup.css b/chrome-extension/popup.css index ac4d502..229af46 100644 --- a/chrome-extension/popup.css +++ b/chrome-extension/popup.css @@ -4,117 +4,277 @@ 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: 360px; + min-width: 380px; + max-width: 420px; +} + +body[dir="rtl"] { + direction: rtl; } .container { - padding: 16px; + 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 0 4px; - font-size: 1rem; + margin: 0; + font-size: 1.1rem; + font-weight: 700; + color: var(--primary); } header p { - margin: 0 0 16px; - color: #475569; - font-size: 0.95rem; + 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: 18px; - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 14px; - padding: 14px; + 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 8px; - font-size: 0.95rem; + 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 12px; + margin: 0 0 14px; line-height: 1.5; + color: #64748b; } .row { display: flex; flex-wrap: wrap; - gap: 8px; - margin-bottom: 12px; + gap: 10px; + margin-bottom: 14px; } .row.small { - gap: 4px; + gap: 6px; } button { border: none; - border-radius: 10px; - padding: 10px 14px; - font-size: 0.92rem; + border-radius: 12px; + padding: 12px 16px; + font-size: 0.9rem; + font-weight: 500; cursor: pointer; - background: #0f172a; + background: var(--primary); color: #fff; - transition: transform 0.1s ease, background 0.2s ease; + 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.secondary { - background: #e2e8f0; - color: #111827; + 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: 10px; - color: #334155; + margin-bottom: 12px; + color: #374151; + font-weight: 500; } input, textarea { width: 100%; - border: 1px solid #cbd5e1; - border-radius: 10px; - padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px 14px; font-family: inherit; font-size: 0.95rem; - background: #f8fafc; + 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: 88px; - max-height: 220px; + min-height: 100px; + max-height: 240px; overflow: auto; } .notice { - background: #f8fafc; - border-left: 4px solid #60a5fa; - padding: 10px 12px; - color: #334155; - font-size: 0.92rem; - border-radius: 0 0 10px 10px; + 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: 0; - padding-top: 4px; + margin-top: 16px; + padding-top: 8px; + text-align: center; } #message { - color: #0f172a; + 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; +} + +#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 index ee083f8..c6782b5 100644 --- a/chrome-extension/popup.html +++ b/chrome-extension/popup.html @@ -1,5 +1,5 @@ <!DOCTYPE html> -<html lang="en"> +<html lang="en" dir="ltr"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> @@ -8,56 +8,66 @@ </head> <body> <div class="container"> + <header> + <div class="header-row"> + <h1>mhrv-rs Apps Script Helper</h1> + <select id="language-select"> + <option value="en">English</option> + <option value="fa">فارسی</option> + </select> + </div> + <p>Generate Google Apps Script deployment code and configuration for mhrv-rs.</p> + </header> + <section class="step"> - <h2>Step 0: Download mhrv-rs</h2> - <p>Get the latest Rust binary for your platform.</p> + <h2 data-i18n="step0Title">Step 0: Download mhrv-rs</h2> + <p data-i18n="step0Desc">Get the latest Rust binary for your platform.</p> <div class="row"> - <button id="download-rust">Download mhrv-rs</button> - <button id="open-releases" class="secondary">View all releases</button> + <button id="download-rust" data-i18n="downloadRust">Download mhrv-rs</button> + <button id="open-releases" class="secondary" data-i18n="viewReleases">View all releases</button> </div> </section> <section class="step"> - <h2>Step 1: Generate a strong auth key</h2> - <p>This secret is used by the Apps Script and your local config.</p> + <h2 data-i18n="step1Title">Step 1: Generate a strong auth key</h2> + <p data-i18n="step1Desc">This secret is used by the Apps Script and your local config.</p> <div class="row"> - <button id="generate-key">Generate auth key</button> - <button id="copy-key" class="secondary">Copy key</button> + <button id="generate-key" data-i18n="generateKey">Generate auth key</button> + <button id="copy-key" class="secondary" data-i18n="copyKey">Copy key</button> </div> <textarea id="auth-key" readonly aria-label="Generated auth key"></textarea> </section> <section class="step"> - <h2>Step 2: Build your Apps Script</h2> - <p>Open Apps Script, paste the generated code, and deploy as a Web app.</p> + <h2 data-i18n="step2Title">Step 2: Build your Apps Script</h2> + <p data-i18n="step2Desc">Open Apps Script, paste the generated code, and deploy as a Web app.</p> <div class="row"> - <button id="copy-script">Copy Code.gs</button> - <button id="download-script" class="secondary">Download Code.gs</button> - <button id="open-script" class="secondary">Open Apps Script</button> + <button id="copy-script" data-i18n="copyScript">Copy Code.gs</button> + <button id="download-script" class="secondary" data-i18n="downloadScript">Download Code.gs</button> + <button id="open-script" class="secondary" data-i18n="openScript">Open Apps Script</button> </div> - <div class="notice">The script is loaded from the extension package and includes the same relay protocol used by mhrv-rs.</div> + <div class="progress" id="script-progress"></div> + <div class="notice" data-i18n="scriptNotice">The script is loaded from the extension package and includes the same relay protocol used by mhrv-rs.</div> </section> <section class="step"> - <h2>Step 3: Create config</h2> - <p>Paste your Deployment ID and the auth key into your local config.</p> - <label> - Deployment ID - <input id="deployment-id" type="text" placeholder="YOUR_APPS_SCRIPT_DEPLOYMENT_ID" /> - </label> + <h2 data-i18n="step3Title">Step 3: Create config</h2> + <p data-i18n="step3Desc">Paste your Deployment ID and the auth key into your local config.</p> + <label data-i18n="deploymentIdLabel">Deployment ID</label> + <input id="deployment-id" type="text" data-i18n-placeholder="deploymentIdPlaceholder" placeholder="YOUR_APPS_SCRIPT_DEPLOYMENT_ID" /> <div class="row"> - <button id="copy-config">Copy config snippet</button> + <button id="copy-config" data-i18n="copyConfig">Copy config snippet</button> </div> <textarea id="config-json" readonly aria-label="Generated config snippet"></textarea> </section> <section class="step note"> - <h2>Quick links</h2> + <h2 data-i18n="linksTitle">Quick links</h2> <div class="row small"> - <button id="open-readme">View app docs</button> - <button id="open-guide" class="secondary">Open setup guide</button> + <button id="open-readme" data-i18n="viewDocs">View app docs</button> + <button id="open-guide" class="secondary" data-i18n="setupGuide">Open setup guide</button> </div> - <p>Use a local browser proxy such as Proxy SwitchyOmega for Chrome/Edge.</p> + <p data-i18n="proxyNote">Use a local browser proxy such as Proxy SwitchyOmega for Chrome/Edge.</p> </section> <footer> diff --git a/chrome-extension/popup.js b/chrome-extension/popup.js index 8062c96..b71322d 100644 --- a/chrome-extension/popup.js +++ b/chrome-extension/popup.js @@ -1,8 +1,10 @@ const AUTH_KEY_PLACEHOLDER = 'CHANGE_ME_TO_A_STRONG_SECRET'; const CODE_FILE = 'Code.gs'; let codeTemplate = ''; +let messages = {}; const elements = { + languageSelect: document.getElementById('language-select'), authKey: document.getElementById('auth-key'), deploymentId: document.getElementById('deployment-id'), configJson: document.getElementById('config-json'), @@ -17,8 +19,70 @@ const elements = { openGuide: document.getElementById('open-guide'), downloadRust: document.getElementById('download-rust'), openReleases: document.getElementById('open-releases'), + scriptProgress: document.getElementById('script-progress'), }; +async function loadMessages() { + try { + const response = await fetch(chrome.runtime.getURL('messages.json')); + messages = await response.json(); + } catch (err) { + console.error('Failed to load messages:', err); + // Fallback messages + messages = { + en: { + 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." + } + }; + } +} + +function getMessage(key, params = {}) { + const lang = elements.languageSelect.value; + let message = messages[lang]?.[key] || messages.en?.[key] || key; + + // Replace placeholders + Object.keys(params).forEach(param => { + message = message.replace(`{${param}}`, params[param]); + }); + + return message; +} + +function updateUILanguage() { + const lang = elements.languageSelect.value; + document.documentElement.lang = lang; + document.documentElement.dir = lang === 'fa' ? 'rtl' : 'ltr'; + + // Update all elements with data-i18n attributes + document.querySelectorAll('[data-i18n]').forEach(el => { + const key = el.getAttribute('data-i18n'); + el.textContent = getMessage(key); + }); + + // Update placeholders + document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { + const key = el.getAttribute('data-i18n-placeholder'); + el.placeholder = getMessage(key); + }); + + // Update title + document.title = getMessage('appName'); + + // Re-render config to update any language-specific text + renderConfig(); +} + function randomHex(length = 32) { const array = new Uint8Array(length); crypto.getRandomValues(array); @@ -29,7 +93,7 @@ function randomHex(length = 32) { function showMessage(text, isError = false) { elements.message.textContent = text; - elements.message.style.color = isError ? '#b91c1c' : '#0f172a'; + elements.message.className = isError ? 'error' : ''; } function renderConfig() { @@ -79,14 +143,15 @@ function setAuthKey(key) { } async function loadTemplate() { + elements.scriptProgress.style.display = 'block'; try { const response = await fetch(CODE_FILE_URL); if (!response.ok) throw new Error('Failed to fetch Code.gs'); codeTemplate = await response.text(); renderScript(); - showMessage('Code.gs loaded from repository.'); + showMessage(getMessage('codeLoaded')); } catch (err) { - showMessage('Failed to load Code.gs from repository. Using local fallback.', true); + showMessage(getMessage('codeLoadedFallback'), true); console.error(err); // Fallback to local if fetch fails try { @@ -94,17 +159,19 @@ async function loadTemplate() { codeTemplate = await localResponse.text(); renderScript(); } catch (localErr) { - showMessage('Could not load Code.gs at all.', true); + showMessage(getMessage('fetchError'), true); } + } finally { + elements.scriptProgress.style.display = 'none'; } } function copyText(text, label) { return navigator.clipboard.writeText(text).then( - () => showMessage(`Copied ${label}.`), + () => showMessage(getMessage('copied', { item: label })), (err) => { console.error(err); - showMessage(`Could not copy ${label}.`, true); + showMessage(getMessage('copyError', { item: label }), true); } ); } @@ -130,24 +197,26 @@ async function downloadLatestRust() { return; } window.open(assetName.browser_download_url, '_blank'); - showMessage('Opening download page for latest mhrv-rs binary.'); + showMessage(getMessage('downloadSuccess')); } catch (err) { console.error(err); - showMessage('Failed to fetch latest release. Opening releases page.', true); + showMessage(getMessage('downloadError'), true); window.open('https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases', '_blank'); } } function initListeners() { + elements.languageSelect.addEventListener('change', updateUILanguage); + elements.generateKey.addEventListener('click', () => { setAuthKey(randomHex(32)); - showMessage('Auth key generated. Paste it into Apps Script and config.'); + showMessage(getMessage('keyGenerated')); }); elements.copyKey.addEventListener('click', () => { const key = elements.authKey.value.trim(); if (!key) { - showMessage('Generate an auth key first.', true); + showMessage(getMessage('generateKeyFirst'), true); return; } copyText(key, 'auth key'); @@ -155,7 +224,7 @@ function initListeners() { elements.copyScript.addEventListener('click', () => { if (!codeTemplate) { - showMessage('Script template not loaded yet.', true); + showMessage(getMessage('scriptNotLoaded'), true); return; } const authKey = elements.authKey.value.trim() || AUTH_KEY_PLACEHOLDER; @@ -168,7 +237,7 @@ function initListeners() { elements.downloadScript.addEventListener('click', () => { if (!codeTemplate) { - showMessage('Script template not loaded yet.', true); + showMessage(getMessage('scriptNotLoaded'), true); return; } const authKey = elements.authKey.value.trim() || AUTH_KEY_PLACEHOLDER; @@ -183,7 +252,7 @@ function initListeners() { anchor.download = 'Code.gs'; anchor.click(); URL.revokeObjectURL(url); - showMessage('Downloaded Code.gs for Apps Script deployment.'); + showMessage(getMessage('scriptDownloaded')); }); elements.openScript.addEventListener('click', () => { @@ -195,11 +264,11 @@ function initListeners() { }); elements.openReadme.addEventListener('click', () => { - window.open('https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/assets/apps_script/README.md', '_blank'); + window.open('https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/README.md', '_blank'); }); elements.openGuide.addEventListener('click', () => { - window.open('https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/README.md', '_blank'); + window.open('https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/docs/guide.md', '_blank'); }); elements.downloadRust.addEventListener('click', () => downloadLatestRust()); @@ -208,10 +277,12 @@ function initListeners() { elements.deploymentId.addEventListener('input', renderConfig); } -function init() { +async function init() { + await loadMessages(); loadTemplate(); initListeners(); renderConfig(); + updateUILanguage(); } init(); From e23b7f99978c5bea0ce9ab3a36e62bec29ce6132 Mon Sep 17 00:00:00 2001 From: "a.abdollahian" <itsardalan007@gmail.com> Date: Tue, 12 May 2026 09:50:05 +0330 Subject: [PATCH 5/9] feat: add testing infrastructure and docs - Add test.html for UI testing without extension restrictions - Update README with comprehensive testing instructions - Add validation commands for JSON/JS syntax checking - Document manual and automated testing procedures --- chrome-extension/README.md | 66 ++++++++++++------------- chrome-extension/test.html | 99 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 34 deletions(-) create mode 100644 chrome-extension/test.html diff --git a/chrome-extension/README.md b/chrome-extension/README.md index 7a8d897..e57c404 100644 --- a/chrome-extension/README.md +++ b/chrome-extension/README.md @@ -43,37 +43,35 @@ This Chrome extension is a lightweight helper for the `MasterHttpRelayVPN-RUST` 8. Copy the deployment ID and paste it into the Deployment ID field in the extension. 9. Tap **Copy config snippet** and paste the result into your local `config.json`. -## Automation Level - -The extension automates as much as possible within Chrome extension limitations: - -- ✅ Generates secure keys -- ✅ Fetches latest script code from repo -- ✅ Prepares deployment-ready code -- ✅ Generates config snippets -- ❌ Cannot automatically deploy to Google Apps Script (requires manual paste and deploy due to OAuth/security restrictions) - -Full automation of Apps Script deployment would require: -- Google OAuth integration -- Apps Script API access -- Publishing as a verified Chrome extension -- User consent for Google account access - -This is beyond the scope of a simple helper extension. - -## Notes - -- The extension fetches `Code.gs` from GitHub on load, ensuring you always get the latest version. -- If GitHub is blocked, it falls back to the bundled local copy. -- The extension does not store secret values persistently in Chrome storage. -- If your network does not allow `script.google.com`, use the project in `direct` mode first and then follow the guide. - -## Recommended workflow - -- Use the extension to avoid manual editing mistakes. -- Keep the generated `AUTH_KEY` secret. -- If you need full tunnel mode later, use the repo docs to deploy `CodeFull.gs` or `Code.cfw.gs`. - -## Limitations - -This helper is intentionally minimal and does not perform OAuth on behalf of your Google account. It simplifies the code generation and setup flow but still requires a manual Apps Script deployment step inside Google. +## Testing the Extension + +### Manual Testing +1. Load the extension in Chrome as described in Installation +2. Click the extension icon +3. Test language switching (English ↔ Persian) +4. Generate an auth key and verify it's 64 characters +5. Test copying functionality (key, script, config) +6. Test download buttons (should open new tabs) +7. Verify RTL layout works in Persian mode + +### Automated Testing +Open `test.html` in a browser to test the UI without Chrome extension restrictions: +```bash +# In chrome-extension folder +python3 -m http.server 8000 +# Then open http://localhost:8000/test.html +``` + +### Validation Checks +```bash +# Check JSON syntax +python3 -m json.tool manifest.json +python3 -m json.tool messages.json + +# Check JavaScript syntax +node -c popup.js + +# Verify file structure +ls -la +# Should show: Code.gs, manifest.json, messages.json, popup.css, popup.html, popup.js, README.md, test.html +``` diff --git a/chrome-extension/test.html b/chrome-extension/test.html new file mode 100644 index 0000000..cc16db7 --- /dev/null +++ b/chrome-extension/test.html @@ -0,0 +1,99 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Extension Test + + + +
+
+
+

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.

+
+ +
+ +
+
+ + + + + \ No newline at end of file From d7bfb524ab6dece4d43848dfe6c85bcb2c394f03 Mon Sep 17 00:00:00 2001 From: "a.abdollahian" Date: Tue, 12 May 2026 10:25:45 +0330 Subject: [PATCH 6/9] feat: add English and Persian localization files and update popup.js with code file URL Co-authored-by: Copilot --- chrome-extension/_locales/en/messages.json | 8 ++++++++ chrome-extension/_locales/fa/messages.json | 8 ++++++++ chrome-extension/popup.js | 1 + 3 files changed, 17 insertions(+) create mode 100644 chrome-extension/_locales/en/messages.json create mode 100644 chrome-extension/_locales/fa/messages.json diff --git a/chrome-extension/_locales/en/messages.json b/chrome-extension/_locales/en/messages.json new file mode 100644 index 0000000..0b3c14e --- /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 0000000..65847b5 --- /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/popup.js b/chrome-extension/popup.js index b71322d..d639a55 100644 --- a/chrome-extension/popup.js +++ b/chrome-extension/popup.js @@ -1,5 +1,6 @@ const AUTH_KEY_PLACEHOLDER = 'CHANGE_ME_TO_A_STRONG_SECRET'; const CODE_FILE = 'Code.gs'; +const CODE_FILE_URL = 'https://raw.githubusercontent.com/therealaleph/MasterHttpRelayVPN-RUST/main/assets/apps_script/Code.gs'; let codeTemplate = ''; let messages = {}; From 2ff89a3f50332a930b4a9068f1bb4f2292e0b225 Mon Sep 17 00:00:00 2001 From: "a.abdollahian" Date: Tue, 12 May 2026 10:37:16 +0330 Subject: [PATCH 7/9] feat: update manifest.json to use localization strings for name and description --- chrome-extension/manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index fc7cc8d..16d0cbc 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -1,11 +1,11 @@ { "manifest_version": 3, - "name": "mhrv-rs Apps Script Helper", - "description": "Generate Google Apps Script deployment code and configuration for mhrv-rs.", + "name": "__MSG_extName__", + "description": "__MSG_extDescription__", "version": "0.2.0", "default_locale": "en", "action": { - "default_title": "mhrv-rs Apps Script Helper", + "default_title": "__MSG_extName__", "default_popup": "popup.html" }, "permissions": ["storage", "clipboardWrite"], From d0be6093b3be4cc2b7270b41f0b41ede53067e50 Mon Sep 17 00:00:00 2001 From: "a.abdollahian" Date: Wed, 13 May 2026 14:11:44 +0330 Subject: [PATCH 8/9] chore(chrome-extension): CI sync, smoke tests, background worker Adds Code.gs sync check workflow, release gate, Playwright AUTH_KEY smoke test, empty MV3 service worker for test discovery, and extension assets. Co-authored-by: Cursor --- .github/workflows/chrome-extension.yml | 61 ++++++++++++++ .github/workflows/release.yml | 16 ++++ chrome-extension/.gitignore | 29 +++++++ chrome-extension/assets/logo.webp | Bin 0 -> 61682 bytes chrome-extension/background.js | 2 + chrome-extension/manifest.json | 3 + chrome-extension/messages.json | 12 +++ chrome-extension/package-lock.json | 76 ++++++++++++++++++ chrome-extension/package.json | 10 +++ chrome-extension/playwright.config.js | 27 +++++++ chrome-extension/popup.css | 4 + chrome-extension/popup.html | 1 + chrome-extension/popup.js | 54 +++++++++++++ chrome-extension/tests/auth-key-smoke.spec.js | 74 +++++++++++++++++ 14 files changed, 369 insertions(+) create mode 100644 .github/workflows/chrome-extension.yml create mode 100644 chrome-extension/.gitignore create mode 100644 chrome-extension/assets/logo.webp create mode 100644 chrome-extension/background.js create mode 100644 chrome-extension/package-lock.json create mode 100644 chrome-extension/package.json create mode 100644 chrome-extension/playwright.config.js create mode 100644 chrome-extension/tests/auth-key-smoke.spec.js diff --git a/.github/workflows/chrome-extension.yml b/.github/workflows/chrome-extension.yml new file mode 100644 index 0000000..f3b7953 --- /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 51630c8..815ff15 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/chrome-extension/.gitignore b/chrome-extension/.gitignore new file mode 100644 index 0000000..3658295 --- /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/assets/logo.webp b/chrome-extension/assets/logo.webp new file mode 100644 index 0000000000000000000000000000000000000000..75e3ceab4a6e6aecb0ccec6ae737de8d99be6277 GIT binary patch literal 61682 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
FAzqeYkvkXPgadVi+m#&l9)9Zq;D=P8=zSOf9rd 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 literal 0 HcmV?d00001 diff --git a/chrome-extension/background.js b/chrome-extension/background.js new file mode 100644 index 0000000..aea5906 --- /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 index 16d0cbc..07ebb20 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -8,6 +8,9 @@ "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 index 39f61fc..28701fe 100644 --- a/chrome-extension/messages.json +++ b/chrome-extension/messages.json @@ -16,6 +16,7 @@ "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", @@ -36,6 +37,11 @@ "generateKeyFirst": "Generate an auth key first.", "copyError": "Could not copy {item}.", "fetchError": "Failed to load Code.gs at all." + , + "scriptUpToDate": "Your bundled Code.gs matches the latest upstream version.", + "scriptOutdated": "Update available: your bundled Code.gs differs from upstream.", + "scriptCheckFailed": "Could not verify Code.gs version.", + "scriptCheckNetworkBlocked": "Could not reach upstream to check Code.gs (network blocked/offline)." }, "fa": { "appName": "کمک‌کننده اسکریپت اپس mhrv-rs", @@ -54,6 +60,7 @@ "downloadScript": "دانلود Code.gs", "openScript": "باز کردن Apps Script", "scriptNotice": "اسکریپت از بسته افزونه بارگذاری می‌شود و شامل همان پروتکل رله استفاده شده توسط mhrv-rs است.", + "checkScriptVersion": "بررسی آخرین نسخه Code.gs", "step3Title": "مرحله ۳: ایجاد پیکربندی", "step3Desc": "شناسه استقرار و کلید احراز هویت را در پیکربندی محلی خود جای‌گذاری کنید.", "deploymentIdLabel": "شناسه استقرار", @@ -74,5 +81,10 @@ "generateKeyFirst": "ابتدا یک کلید احراز هویت تولید کنید.", "copyError": "کپی {item} امکان‌پذیر نبود.", "fetchError": "بارگذاری Code.gs به طور کامل ناموفق بود." + , + "scriptUpToDate": "Code.gs داخلی افزونه با آخرین نسخه upstream یکسان است.", + "scriptOutdated": "به‌روزرسانی موجود است: Code.gs داخلی افزونه با upstream متفاوت است.", + "scriptCheckFailed": "امکان بررسی نسخه Code.gs وجود ندارد.", + "scriptCheckNetworkBlocked": "امکان دسترسی به upstream برای بررسی 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 0000000..429311d --- /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 0000000..aba0ca4 --- /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 0000000..710942a --- /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 index 229af46..4a93d79 100644 --- a/chrome-extension/popup.css +++ b/chrome-extension/popup.css @@ -172,6 +172,10 @@ 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); diff --git a/chrome-extension/popup.html b/chrome-extension/popup.html index c6782b5..3ecb941 100644 --- a/chrome-extension/popup.html +++ b/chrome-extension/popup.html @@ -44,6 +44,7 @@

Step 2: Build your Apps Script

+
diff --git a/chrome-extension/popup.js b/chrome-extension/popup.js index d639a55..5af61d0 100644 --- a/chrome-extension/popup.js +++ b/chrome-extension/popup.js @@ -14,6 +14,7 @@ const elements = { copyKey: document.getElementById('copy-key'), copyScript: document.getElementById('copy-script'), downloadScript: document.getElementById('download-script'), + checkScriptVersion: document.getElementById('check-script-version'), openScript: document.getElementById('open-script'), copyConfig: document.getElementById('copy-config'), openReadme: document.getElementById('open-readme'), @@ -23,6 +24,18 @@ const elements = { scriptProgress: document.getElementById('script-progress'), }; +function toHex(bytes) { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +async function sha256(text) { + const data = new TextEncoder().encode(text); + const digest = await crypto.subtle.digest('SHA-256', data); + return toHex(new Uint8Array(digest)); +} + async function loadMessages() { try { const response = await fetch(chrome.runtime.getURL('messages.json')); @@ -177,6 +190,45 @@ function copyText(text, label) { ); } +async function checkScriptVersion() { + elements.scriptProgress.style.display = 'block'; + try { + const [remoteResp, localResp] = await Promise.all([ + fetch(CODE_FILE_URL, { cache: 'no-store' }), + fetch(chrome.runtime.getURL(CODE_FILE)), + ]); + + if (!localResp.ok) { + showMessage(getMessage('scriptCheckFailed'), true); + return; + } + + const localText = await localResp.text(); + const localHash = await sha256(localText); + + if (!remoteResp.ok) { + // Network / censorship realities: just report we couldn't check. + showMessage(getMessage('scriptCheckNetworkBlocked'), true); + return; + } + + const remoteText = await remoteResp.text(); + const remoteHash = await sha256(remoteText); + + if (remoteHash === localHash) { + showMessage(getMessage('scriptUpToDate')); + return; + } + + showMessage(getMessage('scriptOutdated'), true); + } catch (err) { + console.error(err); + showMessage(getMessage('scriptCheckFailed'), true); + } finally { + elements.scriptProgress.style.display = 'none'; + } +} + async function downloadLatestRust() { try { const response = await fetch('https://api.github.com/repos/therealaleph/MasterHttpRelayVPN-RUST/releases/latest'); @@ -272,6 +324,8 @@ function initListeners() { window.open('https://github.com/therealaleph/MasterHttpRelayVPN-RUST/blob/main/docs/guide.md', '_blank'); }); + elements.checkScriptVersion.addEventListener('click', () => checkScriptVersion()); + elements.downloadRust.addEventListener('click', () => downloadLatestRust()); elements.openReleases.addEventListener('click', () => window.open('https://github.com/therealaleph/MasterHttpRelayVPN-RUST/releases', '_blank')); diff --git a/chrome-extension/tests/auth-key-smoke.spec.js b/chrome-extension/tests/auth-key-smoke.spec.js new file mode 100644 index 0000000..d5fdc85 --- /dev/null +++ b/chrome-extension/tests/auth-key-smoke.spec.js @@ -0,0 +1,74 @@ +const { test, expect, chromium } = require('@playwright/test'); +const path = require('path'); + +function containsSecret(value, secret) { + return typeof value === 'string' && value.includes(secret); +} + +test('AUTH_KEY stays local to popup scope', async () => { + const extensionPath = path.resolve(__dirname, '..'); + const context = await chromium.launchPersistentContext('', { + channel: 'chromium', + headless: false, + args: [ + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + ], + }); + + const requestSnapshots = []; + const consoleMessages = []; + let generatedKey = ''; + + context.on('request', (req) => { + requestSnapshots.push({ + url: req.url(), + postData: req.postData() || '', + headers: JSON.stringify(req.headers()), + }); + }); + + try { + let [worker] = context.serviceWorkers(); + if (!worker) { + worker = await context.waitForEvent('serviceworker'); + } + + const extensionId = worker.url().split('/')[2]; + const page = await context.newPage(); + + page.on('console', (msg) => { + consoleMessages.push(msg.text()); + }); + + await page.goto(`chrome-extension://${extensionId}/popup.html`); + + await page.getByRole('button', { name: /Generate auth key|تولید کلید/ }).click(); + generatedKey = await page.locator('#auth-key').inputValue(); + expect(generatedKey).toMatch(/^[a-f0-9]{64}$/); + + await page.getByRole('button', { name: /Copy key|کپی کلید/ }).click(); + await page.getByRole('button', { name: /Copy Code\.gs|کپی Code\.gs/ }).click(); + await page.getByRole('button', { name: /Copy config snippet|کپی قطعه پیکربندی/ }).click(); + + const [localStorageData, syncStorageData] = await Promise.all([ + page.evaluate(() => new Promise((resolve) => chrome.storage.local.get(null, resolve))), + page.evaluate(() => new Promise((resolve) => chrome.storage.sync.get(null, resolve))), + ]); + + expect(JSON.stringify(localStorageData)).not.toContain(generatedKey); + expect(JSON.stringify(syncStorageData)).not.toContain(generatedKey); + + for (const req of requestSnapshots) { + expect(containsSecret(req.url, generatedKey)).toBeFalsy(); + expect(containsSecret(req.postData, generatedKey)).toBeFalsy(); + expect(containsSecret(req.headers, generatedKey)).toBeFalsy(); + } + + for (const line of consoleMessages) { + expect(containsSecret(line, generatedKey)).toBeFalsy(); + } + } finally { + await context.close(); + } +}); From e27921b12bd12ba1718d044834a0b606e8015ddf Mon Sep 17 00:00:00 2001 From: "a.abdollahian" Date: Wed, 13 May 2026 15:09:57 +0330 Subject: [PATCH 9/9] feat(ci sync & betterdocs):add how to use docs and ci sync --- README.md | 12 +- .../.github/workflows/sync-codegs.yml | 36 +++++ chrome-extension/HOW_TO_USE.fa.md | 111 +++++++++++++ chrome-extension/HOW_TO_USE.md | 111 +++++++++++++ chrome-extension/README.fa.md | 88 +++++++++++ chrome-extension/README.md | 147 ++++++++++-------- chrome-extension/messages.json | 18 ++- chrome-extension/popup.css | 4 + chrome-extension/popup.js | 132 ++++++++++++++-- 9 files changed, 574 insertions(+), 85 deletions(-) create mode 100644 chrome-extension/.github/workflows/sync-codegs.yml create mode 100644 chrome-extension/HOW_TO_USE.fa.md create mode 100644 chrome-extension/HOW_TO_USE.md create mode 100644 chrome-extension/README.fa.md diff --git a/README.md b/README.md index f7e7beb..8c500f8 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) ``` @@ -75,7 +79,7 @@ ISPs can't read inside encrypted HTTPS. They only see the address — `www.googl > **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 `chrome-extension/README.md` for a browser helper that generates `Code.gs` and local config automatically. +> **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 @@ -200,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). + ## چطور کار می‌کند (تصویر ساده) ``` @@ -246,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 0000000..c62b797 --- /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/HOW_TO_USE.fa.md b/chrome-extension/HOW_TO_USE.fa.md new file mode 100644 index 0000000..0a19c73 --- /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 0000000..35d431b --- /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 0000000..8c7001b --- /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 index e57c404..da6f96e 100644 --- a/chrome-extension/README.md +++ b/chrome-extension/README.md @@ -1,77 +1,94 @@ -# mhrv-rs Apps Script Helper Chrome Extension +# mhrv-rs Apps Script Helper (Chrome extension) -This Chrome extension is a lightweight helper for the `MasterHttpRelayVPN-RUST` project. It automates the first-time Apps Script setup by generating a strong `AUTH_KEY`, fetching the latest `Code.gs` source from the repository, and producing a local config snippet. +English | [فارسی](README.fa.md) -## ✨ New Features (v0.2.0) -- 🌐 **Multilingual Support**: English and Persian (فارسی) interface -- 🎨 **Modern UI**: Improved design with icons, animations, and better UX -- 📱 **RTL Support**: Proper right-to-left layout for Persian -- 🔄 **Loading Indicators**: Visual feedback during script fetching -- 📦 **Auto-Updates**: Always fetches latest Code.gs from repository -- 🏗️ **Better Architecture**: Modular code with i18n support +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 -- Downloads the latest `mhrv-rs` binary for your platform -- Generates a strong random `AUTH_KEY` -- Fetches the latest `Code.gs` from the GitHub repository (with local fallback) -- Creates a ready-to-deploy `Code.gs` file with the same relay protocol used by the repo -- Opens Google Apps Script in a new tab -- Builds a JSON config snippet for `config.json` -- Links to the repo documentation for setup and troubleshooting - -## Installation - -1. Open Chrome and go to `chrome://extensions`. -2. Enable **Developer mode**. -3. Click **Load unpacked**. -4. Select the `chrome-extension` folder in this repository. -5. The extension icon should appear in your toolbar. - -## Usage - -1. Click the extension icon. -2. Select your preferred language (English/Persian) from the dropdown. -3. Tap **Download mhrv-rs** to get the latest binary for your platform. -4. Tap **Generate auth key**. -5. The extension will fetch the latest `Code.gs` from GitHub (shows loading indicator). -6. Tap **Copy Code.gs** or **Download Code.gs**. -7. In `https://script.google.com`, create a new Apps Script project and paste the generated contents. -7. Deploy as a Web App with: - - **Execute as:** Me - - **Who has access:** Anyone -8. Copy the deployment ID and paste it into the Deployment ID field in the extension. -9. Tap **Copy config snippet** and paste the result into your local `config.json`. - -## Testing the Extension - -### Manual Testing -1. Load the extension in Chrome as described in Installation -2. Click the extension icon -3. Test language switching (English ↔ Persian) -4. Generate an auth key and verify it's 64 characters -5. Test copying functionality (key, script, config) -6. Test download buttons (should open new tabs) -7. Verify RTL layout works in Persian mode - -### Automated Testing -Open `test.html` in a browser to test the UI without Chrome extension restrictions: -```bash -# In chrome-extension folder -python3 -m http.server 8000 -# Then open http://localhost:8000/test.html -``` +| 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) -### Validation Checks ```bash -# Check JSON syntax python3 -m json.tool manifest.json python3 -m json.tool messages.json +node --check popup.js +``` -# Check JavaScript syntax -node -c popup.js +### Manual UI (no Chrome APIs) -# Verify file structure -ls -la -# Should show: Code.gs, manifest.json, messages.json, popup.css, popup.html, popup.js, README.md, test.html +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/messages.json b/chrome-extension/messages.json index 28701fe..962cc33 100644 --- a/chrome-extension/messages.json +++ b/chrome-extension/messages.json @@ -38,10 +38,11 @@ "copyError": "Could not copy {item}.", "fetchError": "Failed to load Code.gs at all." , - "scriptUpToDate": "Your bundled Code.gs matches the latest upstream version.", - "scriptOutdated": "Update available: your bundled Code.gs differs from upstream.", - "scriptCheckFailed": "Could not verify Code.gs version.", - "scriptCheckNetworkBlocked": "Could not reach upstream to check Code.gs (network blocked/offline)." + "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", @@ -82,9 +83,10 @@ "copyError": "کپی {item} امکان‌پذیر نبود.", "fetchError": "بارگذاری Code.gs به طور کامل ناموفق بود." , - "scriptUpToDate": "Code.gs داخلی افزونه با آخرین نسخه upstream یکسان است.", - "scriptOutdated": "به‌روزرسانی موجود است: Code.gs داخلی افزونه با upstream متفاوت است.", - "scriptCheckFailed": "امکان بررسی نسخه Code.gs وجود ندارد.", - "scriptCheckNetworkBlocked": "امکان دسترسی به upstream برای بررسی 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/popup.css b/chrome-extension/popup.css index 4a93d79..4c5dbb2 100644 --- a/chrome-extension/popup.css +++ b/chrome-extension/popup.css @@ -258,6 +258,10 @@ footer { 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 { diff --git a/chrome-extension/popup.js b/chrome-extension/popup.js index 5af61d0..3a5c053 100644 --- a/chrome-extension/popup.js +++ b/chrome-extension/popup.js @@ -1,6 +1,14 @@ const AUTH_KEY_PLACEHOLDER = 'CHANGE_ME_TO_A_STRONG_SECRET'; const CODE_FILE = 'Code.gs'; const CODE_FILE_URL = 'https://raw.githubusercontent.com/therealaleph/MasterHttpRelayVPN-RUST/main/assets/apps_script/Code.gs'; +const CODE_GS_API_URL = + 'https://api.github.com/repos/therealaleph/MasterHttpRelayVPN-RUST/contents/assets/apps_script/Code.gs?ref=main'; + +const GITHUB_API_HEADERS = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'mhrv-helper-extension', +}; let codeTemplate = ''; let messages = {}; @@ -55,7 +63,12 @@ async function loadMessages() { 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." + 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.", } }; } @@ -190,11 +203,68 @@ function copyText(text, label) { ); } +async function fetchGithubCodeGsMetadata() { + const response = await fetch(CODE_GS_API_URL, { + cache: 'no-store', + headers: GITHUB_API_HEADERS, + }); + const text = await response.text(); + let json = null; + try { + json = JSON.parse(text); + } catch { + // non-JSON body + } + return { ok: response.ok, status: response.status, json, text }; +} + +function decodeGithubFileContent(json) { + if (json.encoding === 'base64' && typeof json.content === 'string') { + const b64 = json.content.replace(/\n/g, ''); + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return new TextDecoder('utf-8').decode(bytes); + } + return null; +} + +async function getUpstreamCodeGsTextFromApi(json) { + const inline = decodeGithubFileContent(json); + if (inline != null) { + return inline; + } + if (json.download_url) { + const r = await fetch(json.download_url, { cache: 'no-store' }); + if (!r.ok) { + throw new Error(`download_url HTTP ${r.status}`); + } + return r.text(); + } + throw new Error('API response has no file body (content / download_url).'); +} + +function formatApiErrorDetail(status, json, rawText) { + if (json && typeof json.message === 'string') { + return json.message; + } + if (json && typeof json.error === 'string') { + return json.error; + } + const trimmed = (rawText || '').trim(); + if (trimmed) { + return trimmed.slice(0, 240); + } + return `HTTP ${status}`; +} + async function checkScriptVersion() { elements.scriptProgress.style.display = 'block'; try { - const [remoteResp, localResp] = await Promise.all([ - fetch(CODE_FILE_URL, { cache: 'no-store' }), + const [apiResult, localResp] = await Promise.all([ + fetchGithubCodeGsMetadata(), fetch(chrome.runtime.getURL(CODE_FILE)), ]); @@ -206,24 +276,64 @@ async function checkScriptVersion() { const localText = await localResp.text(); const localHash = await sha256(localText); - if (!remoteResp.ok) { - // Network / censorship realities: just report we couldn't check. - showMessage(getMessage('scriptCheckNetworkBlocked'), true); + if (!apiResult.ok) { + const detail = formatApiErrorDetail(apiResult.status, apiResult.json, apiResult.text); + showMessage( + getMessage('scriptCheckApiError', { + status: String(apiResult.status), + detail, + }), + true + ); + return; + } + + const meta = apiResult.json; + const upstreamSha = typeof meta.sha === 'string' ? meta.sha : ''; + const shortSha = upstreamSha.length >= 7 ? upstreamSha.slice(0, 7) : upstreamSha || '—'; + const upstreamSize = + meta.size != null && Number.isFinite(Number(meta.size)) ? String(meta.size) : '—'; + + let upstreamText; + try { + upstreamText = await getUpstreamCodeGsTextFromApi(meta); + } catch (err) { + console.error(err); + showMessage( + getMessage('scriptCheckRawFailed', { detail: err.message || String(err) }), + true + ); return; } - const remoteText = await remoteResp.text(); - const remoteHash = await sha256(remoteText); + const remoteHash = await sha256(upstreamText); if (remoteHash === localHash) { - showMessage(getMessage('scriptUpToDate')); + showMessage( + getMessage('scriptUpToDateDetail', { + sha: shortSha, + size: upstreamSize, + }) + ); return; } - showMessage(getMessage('scriptOutdated'), true); + showMessage( + getMessage('scriptOutdatedDetail', { + sha: shortSha, + size: upstreamSize, + }), + true + ); } catch (err) { console.error(err); - showMessage(getMessage('scriptCheckFailed'), true); + showMessage( + getMessage('scriptCheckApiError', { + status: '—', + detail: err.message || String(err), + }), + true + ); } finally { elements.scriptProgress.style.display = 'none'; }