Offer the userscripts as a download for mobile Violentmonkey (#26) #35

Merged
sulthan merged 4 commits from fix/mobile-userscript-download into main 2026-08-08 16:39:31 +07:00
4 changed files with 40 additions and 1 deletions
Showing only changes of commit 0830016729 - Show all commits
+3 -1
View File
@@ -127,6 +127,8 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
- **Web UI also owns:** session-gated `GET /install/{manga,novel}-bookmark.user.js` - **Web UI also owns:** session-gated `GET /install/{manga,novel}-bookmark.user.js`
(renders the bindmounted script with the acting Reader's derived credential (renders the bindmounted script with the acting Reader's derived credential
substituted in — the credential never appears in page markup, the address substituted in — the credential never appears in page markup, the address
bar, or a redirect) and `POST /rotate-token` (atomic epoch bump + hash bar, or a redirect; `?download=1` adds `Content-Disposition: attachment` for
mobile Violentmonkey, which ignores a `.user.js` navigation) and
`POST /rotate-token` (atomic epoch bump + hash
rewrite; invalidates every installed copy, so the panel warns to reinstall rewrite; invalidates every installed copy, so the panel warns to reinstall
on all devices). on all devices).
@@ -12,6 +12,13 @@
<a class="ghost" href="/install/manga-bookmark.user.js">Install Manga script</a> <a class="ghost" href="/install/manga-bookmark.user.js">Install Manga script</a>
<a class="ghost" href="/install/novel-bookmark.user.js">Install Novels script</a> <a class="ghost" href="/install/novel-bookmark.user.js">Install Novels script</a>
</p> </p>
<p class="setup-copy">On mobile, Violentmonkey does not pick up the install
links — the script opens as text. Download the file instead, then add it
from Violentmonkey's own menu.</p>
<p class="setup-links">
<a class="ghost" href="/install/manga-bookmark.user.js?download=1">Download Manga script</a>
<a class="ghost" href="/install/novel-bookmark.user.js?download=1">Download Novels script</a>
</p>
{{if .Rotated}} {{if .Rotated}}
<p class="setup-warn" role="status">Credential rotated — the old one no <p class="setup-warn" role="status">Credential rotated — the old one no
longer works. Reinstall both scripts on every device now, or they will longer works. Reinstall both scripts on every device now, or they will
+7
View File
@@ -547,6 +547,10 @@ func (h *Handler) uiDelete(w http.ResponseWriter, r *http.Request) {
// derived credential substituted in. The credential is derived, not stored, // derived credential substituted in. The credential is derived, not stored,
// so installs work after any restart; the Reader never types or copies it — // so installs work after any restart; the Reader never types or copies it —
// clicking Install is the whole setup. // clicking Install is the whole setup.
//
// ?download=1 forces a save instead. Mobile Violentmonkey (Chromium) does not
// intercept navigation to a .user.js URL, so the Install link only renders the
// source as text there; the Reader needs the file on disk to add it by hand.
func (h *Handler) installUserscript(name string) http.HandlerFunc { func (h *Handler) installUserscript(name string) http.HandlerFunc {
path := h.mangaUserscriptPath path := h.mangaUserscriptPath
if name == "novel-bookmark.user.js" { if name == "novel-bookmark.user.js" {
@@ -559,6 +563,9 @@ func (h *Handler) installUserscript(name string) http.HandlerFunc {
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
if r.URL.Query().Has("download") {
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
}
userscript.Render(w, r, path, token.Token(h.tokenKey, discordID, epoch)) userscript.Render(w, r, path, token.Token(h.tokenKey, discordID, epoch))
} }
} }
+23
View File
@@ -201,6 +201,27 @@ func TestInstallServesScriptWithCredential(t *testing.T) {
if loc := rr.Header().Get("Location"); loc != "" { if loc := rr.Header().Get("Location"); loc != "" {
t.Fatalf("%s answered with a redirect, credential in Location %q", script, loc) t.Fatalf("%s answered with a redirect, credential in Location %q", script, loc)
} }
// The plain link must stay inline: Violentmonkey's updater polls the
// /u/ path and an attachment disposition there would break updates.
if cd := rr.Header().Get("Content-Disposition"); cd != "" {
t.Fatalf("%s served as %q, want inline", script, cd)
}
// ?download=1 is the mobile path: Violentmonkey on Chromium ignores a
// .user.js navigation, so the Reader saves the file and adds it by hand.
req = httptest.NewRequest(http.MethodGet, "/install/"+script+"?download=1", nil)
req.AddCookie(sessionCookie(t, st))
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("%s?download=1: status = %d, want 200", script, rr.Code)
}
if got, want := rr.Header().Get("Content-Disposition"), `attachment; filename="`+script+`"`; got != want {
t.Fatalf("%s?download=1: Content-Disposition = %q, want %q", script, got, want)
}
if !strings.Contains(rr.Body.String(), `API_TOKEN = "`+ownerCredential()+`"`) {
t.Fatalf("%s?download=1 does not carry the owner's credential", script)
}
} }
} }
@@ -327,6 +348,8 @@ func TestIndexShowsSetupPanelWithoutCredential(t *testing.T) {
for _, want := range []string{ for _, want := range []string{
`href="/install/manga-bookmark.user.js"`, `href="/install/manga-bookmark.user.js"`,
`href="/install/novel-bookmark.user.js"`, `href="/install/novel-bookmark.user.js"`,
`href="/install/manga-bookmark.user.js?download=1"`,
`href="/install/novel-bookmark.user.js?download=1"`,
"Rotate credential", "Rotate credential",
} { } {
if !strings.Contains(body, want) { if !strings.Contains(body, want) {