feat(web): offer the userscripts as a download for mobile
Violentmonkey on mobile Chromium does not intercept navigation to a .user.js URL, so the Install link renders the script as text and there is no way to get it installed. Adding ?download=1 sets Content-Disposition: attachment on the same session-gated endpoint, so the Reader saves the file and adds it from Violentmonkey's own menu. The plain link stays inline on purpose: the updater polls the /u/ path and an attachment disposition there would break auto-update. The test asserts both halves. Refs #26
This commit is contained in:
+3
-1
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user