diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 3ddb6e4..7e24ffa 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -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` (renders the bindmounted script with the acting Reader's derived credential 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 on all devices). diff --git a/backend/internal/web/templates/setup.html b/backend/internal/web/templates/setup.html index d770c6e..9b93bd8 100644 --- a/backend/internal/web/templates/setup.html +++ b/backend/internal/web/templates/setup.html @@ -12,6 +12,13 @@ Install Manga script Install Novels script
+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.
++ Download Manga script + Download Novels script +
{{if .Rotated}}Credential rotated — the old one no longer works. Reinstall both scripts on every device now, or they will diff --git a/backend/internal/web/web.go b/backend/internal/web/web.go index 629750d..f9c0f39 100644 --- a/backend/internal/web/web.go +++ b/backend/internal/web/web.go @@ -547,6 +547,10 @@ func (h *Handler) uiDelete(w http.ResponseWriter, r *http.Request) { // derived credential substituted in. The credential is derived, not stored, // so installs work after any restart; the Reader never types or copies it — // 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 { path := h.mangaUserscriptPath 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) 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)) } } diff --git a/backend/reader_credential_test.go b/backend/reader_credential_test.go index 798140a..e248de5 100644 --- a/backend/reader_credential_test.go +++ b/backend/reader_credential_test.go @@ -201,6 +201,27 @@ func TestInstallServesScriptWithCredential(t *testing.T) { if loc := rr.Header().Get("Location"); 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{ `href="/install/manga-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", } { if !strings.Contains(body, want) {