feat: add PWA support with Android share intent
Make Favoritter installable as a Progressive Web App with offline
static asset caching and Web Share Target API for Android.
New files:
- internal/handler/pwa.go: handlers for manifest, service worker,
and share target
- web/static/sw.js: service worker (cache-first static, network-first
HTML) with {{BASE_PATH}} placeholder for subpath deployments
- web/static/icons/: placeholder PWA icons (192, 512, 512-maskable)
Key design decisions:
- Share target uses GET (not POST) to avoid CSRF token issues — Android
apps cannot provide CSRF tokens
- Manifest is generated dynamically to inject BasePath into start_url,
scope, icon paths, and share_target action
- Service worker served at /sw.js with Cache-Control: no-cache and
BasePath injected via string replacement
- handleShare extracts URLs from Android's "text" field as fallback
(many apps put the URL there instead of "url")
- handleFaveNew replaced with handleFaveNewPreFill that reads url,
description, notes from query params (enables share + bookmarklets)
- SW registration in app.js reads base-path from <meta> tag (CSP-safe)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
485d01ce45
commit
1260cfd18f
10 changed files with 375 additions and 14 deletions
|
|
@ -1133,6 +1133,155 @@ func TestDisplayNameFallbackToUsername(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- PWA ---
|
||||
|
||||
func TestManifestJSON(t *testing.T) {
|
||||
_, mux := testServer(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/manifest.json", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("manifest: got %d, want 200", rr.Code)
|
||||
}
|
||||
|
||||
ct := rr.Header().Get("Content-Type")
|
||||
if !strings.Contains(ct, "manifest+json") {
|
||||
t.Errorf("content-type = %q, want manifest+json", ct)
|
||||
}
|
||||
|
||||
var manifest map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &manifest); err != nil {
|
||||
t.Fatalf("parse manifest: %v", err)
|
||||
}
|
||||
|
||||
if manifest["name"] != "Test" {
|
||||
t.Errorf("name = %v, want Test", manifest["name"])
|
||||
}
|
||||
|
||||
// share_target should exist with GET method.
|
||||
st, ok := manifest["share_target"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("manifest missing share_target")
|
||||
}
|
||||
if st["method"] != "GET" {
|
||||
t.Errorf("share_target method = %v, want GET", st["method"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceWorkerContent(t *testing.T) {
|
||||
_, mux := testServer(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/sw.js", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("sw.js: got %d, want 200", rr.Code)
|
||||
}
|
||||
|
||||
ct := rr.Header().Get("Content-Type")
|
||||
if !strings.Contains(ct, "javascript") {
|
||||
t.Errorf("content-type = %q, want javascript", ct)
|
||||
}
|
||||
|
||||
cc := rr.Header().Get("Cache-Control")
|
||||
if cc != "no-cache" {
|
||||
t.Errorf("Cache-Control = %q, want no-cache", cc)
|
||||
}
|
||||
|
||||
body := rr.Body.String()
|
||||
if strings.Contains(body, "{{BASE_PATH}}") {
|
||||
t.Error("sw.js should have BASE_PATH placeholder replaced")
|
||||
}
|
||||
if !strings.Contains(body, "CACHE_NAME") {
|
||||
t.Error("sw.js should contain service worker code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareRedirectsToFaveNew(t *testing.T) {
|
||||
h, mux := testServer(t)
|
||||
cookie := loginUser(t, h, "testuser", "pass123", "user")
|
||||
|
||||
req := httptest.NewRequest("GET", "/share?url=https://example.com&title=Test+Page", nil)
|
||||
req.AddCookie(cookie)
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("share: got %d, want 303", rr.Code)
|
||||
}
|
||||
|
||||
loc := rr.Header().Get("Location")
|
||||
if !strings.Contains(loc, "/faves/new") {
|
||||
t.Errorf("redirect = %q, should point to /faves/new", loc)
|
||||
}
|
||||
if !strings.Contains(loc, "url=https") {
|
||||
t.Errorf("redirect = %q, should contain url param", loc)
|
||||
}
|
||||
if !strings.Contains(loc, "description=Test") {
|
||||
t.Errorf("redirect = %q, should contain description from title", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareTextFieldFallback(t *testing.T) {
|
||||
h, mux := testServer(t)
|
||||
cookie := loginUser(t, h, "testuser", "pass123", "user")
|
||||
|
||||
// Some Android apps put the URL in "text" instead of "url".
|
||||
req := httptest.NewRequest("GET", "/share?text=Check+this+out+https://example.com/article", nil)
|
||||
req.AddCookie(cookie)
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
|
||||
loc := rr.Header().Get("Location")
|
||||
if !strings.Contains(loc, "url=https") {
|
||||
t.Errorf("should extract URL from text field: %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareRequiresLogin(t *testing.T) {
|
||||
_, mux := testServer(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "/share?url=https://example.com", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
t.Errorf("unauthenticated share: got %d, want 303", rr.Code)
|
||||
}
|
||||
loc := rr.Header().Get("Location")
|
||||
if !strings.Contains(loc, "/login") {
|
||||
t.Errorf("should redirect to login: %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFaveNewPreFill(t *testing.T) {
|
||||
h, mux := testServer(t)
|
||||
cookie := loginUser(t, h, "testuser", "pass123", "user")
|
||||
|
||||
req := httptest.NewRequest("GET", "/faves/new?url=https://example.com&description=Shared+Page¬es=Great+article", nil)
|
||||
req.AddCookie(cookie)
|
||||
rr := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("fave new pre-fill: got %d, want 200", rr.Code)
|
||||
}
|
||||
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "https://example.com") {
|
||||
t.Error("URL should be pre-filled")
|
||||
}
|
||||
if !strings.Contains(body, "Shared Page") {
|
||||
t.Error("description should be pre-filled")
|
||||
}
|
||||
if !strings.Contains(body, "Great article") {
|
||||
t.Error("notes should be pre-filled")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Export page ---
|
||||
|
||||
func TestExportPageRendering(t *testing.T) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue