From 9280542b33175dd4517af1e454e142ca43ee4cbb Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Tue, 28 Jul 2026 17:45:59 +0700 Subject: [PATCH 1/6] feat(backend): serve userscript at token-protected path Reads the file per request from USERSCRIPT_PATH and rewrites its @version to an mtime-derived value, so Violentmonkey always sees a higher version after an edit regardless of what the file body claims. Also guards against ServeMux's own path-cleaning redirect turning an empty {token} segment into a 307 instead of the required 404. Co-Authored-By: Claude Opus 5 --- backend/main.go | 36 ++++++++-- backend/userscript.go | 60 +++++++++++++++++ backend/userscript_test.go | 132 +++++++++++++++++++++++++++++++++++++ 3 files changed, 222 insertions(+), 6 deletions(-) create mode 100644 backend/userscript.go create mode 100644 backend/userscript_test.go diff --git a/backend/main.go b/backend/main.go index 2706b78..323fa94 100644 --- a/backend/main.go +++ b/backend/main.go @@ -21,6 +21,9 @@ type Config struct { Port string // WebPassword gates the browser UI. Empty disables the web routes entirely. WebPassword string + // UserscriptPath is the file served at /u/{token}/manga-bookmark.user.js. + // Supplied by a bindmount so the script can be edited without a rebuild. + UserscriptPath string // LatestPoll configures the background latest-chapter fetcher. LatestPoll LatestPoll } @@ -128,11 +131,12 @@ func loadLatestPoll() LatestPoll { func loadConfig() Config { c := Config{ - Token: os.Getenv("API_TOKEN"), - DBPath: envOr("DB_PATH", "/data/bookmarks.db"), - Port: envOr("PORT", "8080"), - WebPassword: os.Getenv("WEB_PASSWORD"), - LatestPoll: loadLatestPoll(), + Token: os.Getenv("API_TOKEN"), + DBPath: envOr("DB_PATH", "/data/bookmarks.db"), + Port: envOr("PORT", "8080"), + WebPassword: os.Getenv("WEB_PASSWORD"), + UserscriptPath: envOr("USERSCRIPT_PATH", "/userscript/manga-bookmark.user.js"), + LatestPoll: loadLatestPoll(), } for _, o := range strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") { if o = strings.TrimSpace(o); o != "" { @@ -149,6 +153,11 @@ func newRouter(store *Store, cfg Config) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", healthz) + // Outside withAuth (the updater sends no Authorization header) and outside + // the WEB_PASSWORD gate (the script must be installable either way). The + // path segment carries the token instead. + mux.HandleFunc("GET /u/{token}/manga-bookmark.user.js", userscriptHandler(cfg.Token, cfg.UserscriptPath)) + h := &bookmarkHandler{store: store} protected := http.NewServeMux() protected.HandleFunc("GET /bookmarks", h.list) @@ -170,7 +179,22 @@ func newRouter(store *Store, cfg Config) http.Handler { web.register(mux) } - return withCORS(cfg.AllowedOrigins, mux) + return withCORS(cfg.AllowedOrigins, guardEmptyUserscriptToken(mux)) +} + +// guardEmptyUserscriptToken heads off ServeMux's own path-cleaning redirect: +// an empty {token} segment makes the request path "/u//manga-bookmark.user.js", +// and ServeMux 307s that to "/u/manga-bookmark.user.js" before pattern +// matching ever runs. The endpoint's contract is 404 for any wrong token, +// including this one, so catch it ahead of the mux. +func guardEmptyUserscriptToken(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/u//") { + http.NotFound(w, r) + return + } + next.ServeHTTP(w, r) + }) } func main() { diff --git a/backend/userscript.go b/backend/userscript.go new file mode 100644 index 0000000..bcb46cd --- /dev/null +++ b/backend/userscript.go @@ -0,0 +1,60 @@ +package main + +import ( + "crypto/subtle" + "log" + "net/http" + "os" + "regexp" + "time" +) + +// versionLine matches the userscript metadata block's @version directive. +var versionLine = regexp.MustCompile(`(?m)^// @version[ \t]+.*$`) + +// stampVersion replaces the served @version with one derived from the file's +// mtime, discarding whatever the file body says. +// +// Violentmonkey only updates when the served version sorts higher than the +// installed one. Deriving it from the body means one accidental downgrade or +// typo freezes updates forever; an mtime-derived version is monotonic by +// construction, so any later write always outranks any earlier one. +// +// A file with no @version line is returned untouched: such a script never +// auto-updates anyway, and inventing a metadata block is not this handler's job. +func stampVersion(src []byte, mod time.Time) []byte { + return versionLine.ReplaceAll(src, []byte("// @version "+mod.UTC().Format("2006.01.02.1504"))) +} + +// userscriptHandler serves the userscript to Violentmonkey's updater. +// +// The token lives in the path because the update poll sends no Authorization +// header, and the file embeds API_TOKEN in plain text, so an open path would +// hand that token to anyone who guessed the URL. A mismatch answers 404 rather +// than 401: a prober learns nothing about whether the route exists. +// +// The file is read per request — that is what lets a bindmounted copy be edited +// on the host without a restart. It is ~50 KB and polled about once a day. +func userscriptHandler(token, path string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if subtle.ConstantTimeCompare([]byte(r.PathValue("token")), []byte(token)) != 1 { + http.NotFound(w, r) + return + } + info, err := os.Stat(path) + if err != nil { + log.Printf("userscript: stat %s: %v", path, err) + http.NotFound(w, r) + return + } + src, err := os.ReadFile(path) + if err != nil { + log.Printf("userscript: read %s: %v", path, err) + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + w.Write(stampVersion(src, info.ModTime())) + } +} diff --git a/backend/userscript_test.go b/backend/userscript_test.go new file mode 100644 index 0000000..fec5b34 --- /dev/null +++ b/backend/userscript_test.go @@ -0,0 +1,132 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// sampleScript is a stand-in for the real userscript: a metadata block with a +// @version line, plus a body that must survive the rewrite untouched. +const sampleScript = `// ==UserScript== +// @name Manga Bookmark Sync +// @version 1.5.0 +// @match https://asurascans.com/* +// ==/UserScript== +(function () { "use strict"; })(); +` + +// writeScript drops a userscript in a temp dir with a known mtime and returns +// its path plus the version string the handler is expected to stamp. +func writeScript(t *testing.T, body string) (path, wantVersion string) { + t.Helper() + path = filepath.Join(t.TempDir(), "manga-bookmark.user.js") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write script: %v", err) + } + mod := time.Date(2026, 7, 28, 16, 42, 0, 0, time.UTC) + if err := os.Chtimes(path, mod, mod); err != nil { + t.Fatalf("chtimes: %v", err) + } + return path, "2026.07.28.1642" +} + +func newUserscriptServer(t *testing.T, path string) http.Handler { + t.Helper() + store, err := OpenStore(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + t.Cleanup(func() { store.Close() }) + cfg := testConfig() + cfg.UserscriptPath = path + return newRouter(store, cfg) +} + +func getScript(t *testing.T, srv http.Handler, token string) *httptest.ResponseRecorder { + t.Helper() + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/u/"+token+"/manga-bookmark.user.js", nil)) + return rr +} + +func TestUserscriptServedWithStampedVersion(t *testing.T) { + path, wantVersion := writeScript(t, sampleScript) + rr := getScript(t, newUserscriptServer(t, path), testToken) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") { + t.Errorf("Content-Type = %q, want text/javascript", ct) + } + if cc := rr.Header().Get("Cache-Control"); cc != "no-cache" { + t.Errorf("Cache-Control = %q, want no-cache", cc) + } + body := rr.Body.String() + if !strings.Contains(body, "// @version "+wantVersion) { + t.Errorf("body has no stamped version %q:\n%s", wantVersion, body) + } + if strings.Contains(body, "1.5.0") { + t.Errorf("body still carries the file's own version:\n%s", body) + } + // Everything outside the @version line is served verbatim. + if !strings.Contains(body, `(function () { "use strict"; })();`) { + t.Errorf("body was altered beyond the version line:\n%s", body) + } + if !strings.Contains(body, "// @name Manga Bookmark Sync") { + t.Errorf("metadata block was altered:\n%s", body) + } +} + +func TestUserscriptWrongTokenIs404(t *testing.T) { + path, _ := writeScript(t, sampleScript) + srv := newUserscriptServer(t, path) + for _, tok := range []string{"wrong", "", testToken + "x", testToken[:3]} { + if got := getScript(t, srv, tok).Code; got != http.StatusNotFound { + t.Errorf("token %q: status = %d, want 404", tok, got) + } + } +} + +func TestUserscriptMissingFileIs404(t *testing.T) { + srv := newUserscriptServer(t, filepath.Join(t.TempDir(), "absent.user.js")) + if got := getScript(t, srv, testToken).Code; got != http.StatusNotFound { + t.Fatalf("status = %d, want 404", got) + } +} + +func TestUserscriptWithoutVersionLineServedUnmodified(t *testing.T) { + const noVersion = "// ==UserScript==\n// @name x\n// ==/UserScript==\nconsole.log(1);\n" + path, _ := writeScript(t, noVersion) + rr := getScript(t, newUserscriptServer(t, path), testToken) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + if rr.Body.String() != noVersion { + t.Fatalf("body = %q, want it unmodified", rr.Body.String()) + } +} + +// The endpoint must work on a deployment that never set WEB_PASSWORD, since +// the web routes are not registered at all in that case. +func TestUserscriptServedWithWebUIDisabled(t *testing.T) { + path, _ := writeScript(t, sampleScript) + store, err := OpenStore(filepath.Join(t.TempDir(), "nopass.db")) + if err != nil { + t.Fatalf("OpenStore: %v", err) + } + t.Cleanup(func() { store.Close() }) + cfg := testConfig() + cfg.WebPassword = "" + cfg.UserscriptPath = path + + if got := getScript(t, newRouter(store, cfg), testToken).Code; got != http.StatusOK { + t.Fatalf("status = %d, want 200", got) + } +} -- 2.52.0 From 4a0950e1cadbc31297cb13f4ab9b12d63a1b2dcd Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Tue, 28 Jul 2026 17:50:39 +0700 Subject: [PATCH 2/6] feat: bindmount userscript and point Violentmonkey at the backend Co-Authored-By: Claude Opus 5 --- AGENTS.md | 2 ++ CLAUDE.md | 2 ++ DEPLOY.md | 37 +++++++++++++++++++++++++++++++ docker-compose.yml | 7 ++++++ userscript/manga-bookmark.user.js | 2 ++ 5 files changed, 50 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1d4003b..fedf19f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,8 @@ Bromite userscript (isolated world, per-site adapters, localStorage cache) (gates browser UI; unset disables it), `LATEST_CHAPTER_POLL_ENABLED`/`_COOLDOWN`/`_INTERVAL`/`_BATCH`/`_STAGGER` (background latest-chapter poller; defaults on, `1h`/`10m`/`14`/`20s`). + `USERSCRIPT_PATH` (file served at `/u/{token}/manga-bookmark.user.js`, + default `/userscript/manga-bookmark.user.js`, supplied by a bindmount). ### Userscript structure (single IIFE, `manga-bookmark.user.js`) diff --git a/CLAUDE.md b/CLAUDE.md index 99c7a4c..0d1f491 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,8 @@ Bromite userscript (isolated world, per-site adapters, localStorage cache) (gates the browser UI; unset disables it), `LATEST_CHAPTER_POLL_ENABLED`/`_COOLDOWN`/`_INTERVAL`/`_BATCH`/`_STAGGER` (background latest-chapter poller; defaults on, `1h`/`10m`/`14`/`20s`). + `USERSCRIPT_PATH` (file served at `/u/{token}/manga-bookmark.user.js`, + default `/userscript/manga-bookmark.user.js`, supplied by a bindmount). ### Userscript structure (single IIFE, `manga-bookmark.user.js`) diff --git a/DEPLOY.md b/DEPLOY.md index 98fbe78..d698cc7 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -218,3 +218,40 @@ SQLite data persists in the named volume `bookmarks-data` across rebuilds. | `compose ... config` errors about `API_TOKEN` | Run compose from the dir with `.env`, or export the vars. | Backend config reference and endpoint list: see `README.md`. + +--- + +## Installing / updating the userscript + +The backend serves the script itself, so Violentmonkey can auto-update it. + +Install once, on the phone (Cromite + Violentmonkey): + +``` +https://manga-api./u//manga-bookmark.user.js +``` + +Open that URL in Cromite; Violentmonkey offers to install it. The token is in +the path because Violentmonkey's update poll sends no `Authorization` header, +and the script embeds `API_TOKEN` in plain text — an open URL would leak it. A +wrong token answers 404. + +Updating, without a redeploy: + +```bash +vi userscript/manga-bookmark.user.js # on the VPS, in this checkout +``` + +`./userscript` is bindmounted read-only into the container and read fresh on +every request, so the edit is live immediately. The served `@version` is derived +from the file's mtime (`YYYY.MM.DD.HHMM`, UTC), not from the `@version` in the +file, so any edit outranks the installed copy and Violentmonkey pulls it on its +next check. The `@version` in the repo is a human marker only. + +Updating via redeploy: `git pull` overwrites the file with the committed +version, which is the intended behaviour — a deploy always ships the repo's +script. Note that `git pull` sets mtime to checkout time, so even a rollback +serves a *higher* version and is adopted. + +If the mount is missing, the endpoint answers 404 and logs it; bookmark sync is +unaffected. diff --git a/docker-compose.yml b/docker-compose.yml index dacce1d..c8adc6b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,8 @@ services: PORT: "8080" # Gates the browser UI. Unset means the web routes are not served at all. WEB_PASSWORD: ${WEB_PASSWORD:-} + # Path inside the container; matches the bindmount above. + USERSCRIPT_PATH: ${USERSCRIPT_PATH:-/userscript/manga-bookmark.user.js} # Latest-chapter poller. LATEST_CHAPTER_POLL_ENABLED=0 in .env is the kill # switch; it only takes effect because these are listed here. LATEST_CHAPTER_POLL_ENABLED: ${LATEST_CHAPTER_POLL_ENABLED:-1} @@ -29,6 +31,11 @@ services: LATEST_CHAPTER_POLL_STAGGER: ${LATEST_CHAPTER_POLL_STAGGER:-20s} volumes: - bookmarks-data:/data + # The userscript is served from here, read fresh on every request. Editing + # the file in this checkout takes effect on the next Violentmonkey poll — + # no rebuild, no restart. `git pull` restores the committed version, which + # is why a redeploy always ships the repo's script. + - ./userscript:/userscript:ro # Bound to loopback only: the proxy (or curl during smoke test) reaches it, # the public internet does not. ports: diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index d598fcb..f074f57 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -4,6 +4,8 @@ // @version 1.5.0 // @description Track read progress on Asura & Demonic and sync to a self-hosted backend. Bromite-compatible (no GM_* APIs). // @author you +// @downloadURL https://manga-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js +// @updateURL https://manga-api.violetcrown.my.id/u/40d79969b5442f90df4fe306a092c7c50e7b4a7a98099f98cc398f4fb374b1df/manga-bookmark.user.js // @match https://asuracomic.net/* // @match https://asurascans.com/* // @match https://demonicscans.org/* -- 2.52.0 From d2916d4403854660fbcd6e165a18141c64db8dfa Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Tue, 28 Jul 2026 17:54:49 +0700 Subject: [PATCH 3/6] fix(userscript): put card actions under the subtitle Only the cover and title continue reading now; the action row is a sibling of the subtitle inside the text column instead of a full-width row below the whole card. Co-Authored-By: Claude Opus 5 --- userscript/manga-bookmark.user.js | 60 +++++++++++++++---------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index f074f57..155d81e 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -1094,37 +1094,36 @@ const sub = behind ? "Read: " + (b.last_chapter || "—") + " · Latest: " + b.latest_chapter : (b.last_chapter || "—") + " · " + b.site; - // Cover and text are one link: the card body *is* the continue button, so - // there is no separate one. The action row is a sibling of that link, never - // a child — a thumb that misses ★ must not land on a navigation target, and - // Remove must never be one. + // Cover and title are the only continue links. The subtitle and the action + // row live in the same text column but are not navigation targets — a thumb + // that misses ★ must not land on one, and Remove must never be one. return el("div", { class: "item" }, [ el("a", { class: "go", href: cont }, [ b.cover ? el("img", { class: "cover", src: b.cover, loading: "lazy", alt: "" }) : el("div", { class: "cover ph" }), - el("div", { class: "meta" }, [ - el("div", { class: "t", text: b.title || b.series_id }), - el("div", { class: "c" + (behind ? " behind" : ""), text: sub }), - ]), ]), - el("div", { class: "actions" }, [ - el("button", { - class: "btn small star" + (b.favorite ? " on" : ""), - text: b.favorite ? "★" : "☆", - title: b.favorite ? "Remove from favourites" : "Add to favourites", - onclick: () => toggleFavorite(b.key), - }), - el("button", { - class: "btn small", - text: statusOf(b) === "archived" ? "Unarchive" : "Archive", - onclick: () => toggleArchive(b.key), - }), - el("button", { - class: "btn small danger", - text: "Remove", - onclick: () => confirmRemove(b), - }), + el("div", { class: "meta" }, [ + el("a", { class: "go-t t", href: cont, text: b.title || b.series_id }), + el("div", { class: "c" + (behind ? " behind" : ""), text: sub }), + el("div", { class: "actions" }, [ + el("button", { + class: "btn small star" + (b.favorite ? " on" : ""), + text: b.favorite ? "★" : "☆", + title: b.favorite ? "Remove from favourites" : "Add to favourites", + onclick: () => toggleFavorite(b.key), + }), + el("button", { + class: "btn small", + text: statusOf(b) === "archived" ? "Unarchive" : "Archive", + onclick: () => toggleArchive(b.key), + }), + el("button", { + class: "btn small danger", + text: "Remove", + onclick: () => confirmRemove(b), + }), + ]), ]), ]); } @@ -1382,16 +1381,17 @@ #list { overflow-y: auto; flex: 1; padding: 8px 0; } .empty { color: #9a9aa5; text-align: center; padding: 30px 16px; font-size: 14px; } .item { - display: flex; flex-direction: column; gap: 8px; + display: flex; flex-direction: row; gap: 10px; padding: 10px 16px; border-bottom: 1px solid #2a2a33; } - .go { display: flex; gap: 10px; text-decoration: none; color: inherit; } - .go:active { opacity: .7; } + .go { display: block; flex: none; } + .go:active, .go-t:active { opacity: .7; } + .go-t { display: block; text-decoration: none; color: inherit; } .cover { width: 46px; height: 62px; object-fit: cover; border-radius: 4px; flex: none; background: #333; } .cover.ph { display: block; } - .meta { min-width: 0; flex: 1; } + .meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 6px; } .t { font-weight: 600; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .c { color: #9a9aa5; font-size: 12px; margin: 2px 0 0; } + .c { color: #9a9aa5; font-size: 12px; margin: 0; } .c.behind { color: #c4b5fd; } .actions { display: flex; gap: 6px; flex-wrap: wrap; } .btn { -- 2.52.0 From d8fa9b651c1d42bf8bfafecc340d63178ec677b4 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Tue, 28 Jul 2026 17:57:49 +0700 Subject: [PATCH 4/6] fix(userscript): show a spinner while the first fetch is in flight Co-Authored-By: Claude Opus 5 --- userscript/manga-bookmark.user.js | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/userscript/manga-bookmark.user.js b/userscript/manga-bookmark.user.js index 155d81e..35f360e 100644 --- a/userscript/manga-bookmark.user.js +++ b/userscript/manga-bookmark.user.js @@ -852,6 +852,9 @@ // ============================================================ let root, panelOpen = false; + // True while refresh() is waiting on the backend. Only changes what the list + // draws when there is nothing cached to draw instead. + let loading = false; let activeTab = "all"; // "all" | "favorites" | "archived"; resets each page, by design function buildUI() { @@ -1071,7 +1074,11 @@ if (s !== "reading") return false; return activeTab !== "favorites" || b.favorite; }); - if (items.length === 0) { + if (items.length === 0 && loading) { + // Nothing cached and the fetch is still out — without this the panel + // looks frozen on the first open after a cold start. + listEl.appendChild(el("div", { class: "empty" }, [el("div", { class: "spinner" })])); + } else if (items.length === 0) { const empty = { favorites: "No favourites yet.", archived: "Nothing archived.", @@ -1154,12 +1161,16 @@ async function refresh() { await drain(); // push what we owe before adopting the server's view of it + loading = true; + render(); try { const list = await apiGet(); setList(overlayPending(list)); - render(); } catch (e) { - render(); // fall back to cache + // Offline or backend down: keep whatever the cache holds. + } finally { + loading = false; + render(); } } @@ -1380,6 +1391,12 @@ .tab.active { color: #eee; border-bottom-color: #6d28d9; } #list { overflow-y: auto; flex: 1; padding: 8px 0; } .empty { color: #9a9aa5; text-align: center; padding: 30px 16px; font-size: 14px; } + .spinner { + width: 26px; height: 26px; margin: 0 auto; + border: 3px solid #33333d; border-top-color: #6d28d9; border-radius: 50%; + animation: spin .8s linear infinite; + } + @keyframes spin { to { transform: rotate(360deg); } } .item { display: flex; flex-direction: row; gap: 10px; padding: 10px 16px; border-bottom: 1px solid #2a2a33; -- 2.52.0 From 91090d4b2f682015c03e36fd8d20e4ddd18f3423 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Tue, 28 Jul 2026 18:05:09 +0700 Subject: [PATCH 5/6] docs: document the userscript endpoint and its metadata lines Co-Authored-By: Claude Opus 5 --- DEPLOY.md | 9 +++++++++ README.md | 1 + 2 files changed, 10 insertions(+) diff --git a/DEPLOY.md b/DEPLOY.md index d698cc7..98bb97f 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -166,6 +166,12 @@ const API_TOKEN = ""; The token sits in the userscript's isolated world — the manga sites' JS can't read it. +Also edit the `@downloadURL`/`@updateURL` metadata lines near the top of the +file — they ship hardcoded to this deployment's domain and token, so a +deployer who skips them ends up auto-updating from someone else's backend. +See "Installing / updating the userscript" below for how those two lines are +used. + --- ## 5. Install on Bromite @@ -224,6 +230,9 @@ Backend config reference and endpoint list: see `README.md`. ## Installing / updating the userscript The backend serves the script itself, so Violentmonkey can auto-update it. +Complements §4 above — that step points `API_BASE`/`API_TOKEN` at your +backend; this one points `@downloadURL`/`@updateURL` at the same place so +auto-updates come from it too. Install once, on the phone (Cromite + Violentmonkey): diff --git a/README.md b/README.md index b0aa729..1e6a83a 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Bromite userscript (isolated world, Shadow DOM UI, localStorage cache) | `PUT` | `/bookmarks/{key}` | Bearer | Upsert one series; returns the row as stored. | | `DELETE` | `/bookmarks/{key}` | Bearer | Remove one. | | `GET` | `/healthz` | none | `200 ok`. | +| `GET` | `/u/{token}/manga-bookmark.user.js` | token in path | Serves the userscript with an mtime-derived `@version`. | `key` is `:` — e.g. `asura:trash-of-the-counts-family-f886a8af` or `demonic:Infinite-Level-Up-in-Murim`. Sync is last-write-wins. -- 2.52.0 From 16e7dce8141c1c85205ee9e6bc2979030a8dbabb Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Tue, 28 Jul 2026 18:13:13 +0700 Subject: [PATCH 6/6] docs: add testing-the-userscript project skill Documents the Node stub harness in userscript/test/logic.test.js: the four globals it installs, why document.body is left undefined, the module.exports test hook, and what is deliberately not testable (no DOM harness). Co-Authored-By: Claude Opus 5 --- .../skills/testing-the-userscript/SKILL.md | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 .claude/skills/testing-the-userscript/SKILL.md diff --git a/.claude/skills/testing-the-userscript/SKILL.md b/.claude/skills/testing-the-userscript/SKILL.md new file mode 100644 index 0000000..e61c484 --- /dev/null +++ b/.claude/skills/testing-the-userscript/SKILL.md @@ -0,0 +1,91 @@ +--- +name: testing-the-userscript +description: Use when writing, running, or debugging tests for userscript/manga-bookmark.user.js — adding a case to logic.test.js, exporting a function for test, a test that fails with "is not a function"/undefined export, or deciding whether some userscript behaviour is testable at all. +--- + +# Testing the userscript + +`userscript/manga-bookmark.user.js` is a browser IIFE, not a module. It is tested +by `require()`-ing it into Node under a hand-written four-object browser stub in +`userscript/test/logic.test.js`. The harness covers **pure logic only** — adapters, +parsers, helpers. UI, network, and storage behaviour are verified on-device. + +## Commands + +```bash +node --check userscript/manga-bookmark.user.js # parse check, silent on success +node --test userscript/test/logic.test.js # 14 tests as of 2026-07-28 +``` + +Run both before every commit that touches the userscript. + +**Use the file path, not `node --test userscript/test/`.** The directory form +fails `MODULE_NOT_FOUND` on this machine's Node v22.22.2. Older docs and plans +still write the directory form — substitute the file path; do not try to fix it. + +## How the harness works + +The test file installs four globals **before** requiring the userscript: + +| Global | What it is | Why | +|---|---|---| +| `localStorage` | `Map`-backed stub | `loadCache`, `loadQueue`, and the key-migration IIFE touch it at module scope | +| `location` | `{href, hostname, pathname, origin}` | read during boot | +| `document` | `querySelector` for `meta[property="…"]` only, plus a no-op `addEventListener` | adapters read `og:title`/`og:image` | +| `document.body` | **left `undefined`** | this is the whole trick | + +`document.body === undefined` sends the userscript's boot block down its `else` +branch, where it waits for a `DOMContentLoaded` that never fires. `init()`, +`buildUI()`, and every `fetch` stay dormant, so nothing else needs stubbing. + +The export hook near the end of the userscript is what makes `require()` work: + +```js +if (typeof window === "undefined" && typeof module === "object" && module.exports) { + module.exports = { stripBuildHash, asura, demonic, anchorsFromHTML, statusOf }; +} +``` + +`typeof window === "undefined"` is load-bearing: under `@grant none` the script +shares page globals, so it must not clobber a page's own UMD shim. + +## Adding a test + +1. If the function isn't already exported, add it to that `module.exports` list + and to the destructuring `require` at the top of `logic.test.js`. A test + failing with `X is not a function` means you skipped this step. +2. Set `metaTags` (module-level `let` in the test file) for anything that reads + `og:` tags — it is reassigned per test, so set every tag your case needs. +3. Build locations with the `loc(href)` helper; `detect()` reads only + `pathname`, `origin`, `href`, `hostname`. +4. Keep the case pure: inputs in, value out, `assert` on the result. + +```js +test("stripBuildHash removes a trailing 8-hex suffix", () => { + assert.equal(stripBuildHash("solo-leveling-059befe1"), "solo-leveling"); +}); +``` + +## What is NOT testable here + +- **DOM, layout, Shadow DOM, the panel, the spinner.** There is no DOM harness + and **you must not add one** — no jsdom, no happy-dom, no second test file for + UI. Verified on-device (Cromite + Violentmonkey) instead. +- **`fetch`, sync, the retry queue's network behaviour.** Verified against a + running backend. +- Anything reachable only through `init()`/`buildUI()`. + +If a change's only meaningful verification is visual or on-device, say so in the +report rather than inventing coverage. + +## Gotchas + +- **New module-scope code that touches a browser API breaks every test**, not + just a new one — the `require()` runs it. Keep such work inside functions that + only `init()` calls. If you must add module-scope access, extend the stub. +- `stripBuildHash` must stay in sync with `asuraBuildHash` in `backend/store.go`; + changing one without the other silently splits series identity. +- `document.querySelector` only understands `meta[property="…"]`. Any other + selector returns `null` — extend the stub rather than working around it. +- The userscript stays GM-free (no `GM_*` APIs); a test that needs one is + testing something that can't ship. -- 2.52.0