diff --git a/CLAUDE.md b/CLAUDE.md index 396b098..6c178ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,11 @@ upward drag near the bottom edge belongs to the system. `swipeOutcome` tests unbound too, but for an unrelated reason — nothing for it to mean — so don't collapse the two rationales into one. +**`draggable="false"` on `#photo` is load-bearing, not tidiness.** An `` is +natively draggable, so without it a mouse press-and-move starts a drag-and-drop, +`pointercancel` fires, and every desktop swipe springs back. Touch is unaffected, +so removing it breaks only the platform you are least likely to be testing on. + **Keys and swipes must resolve to the same outcomes.** `keyOutcome` returns `swipeOutcome`'s vocabulary and both run through `Viewer.commit`, which is what keeps a key press and a gesture from drifting into different behaviour. A new diff --git a/README.md b/README.md index 2619387..d41015e 100644 --- a/README.md +++ b/README.md @@ -354,11 +354,18 @@ An entry screen naming the folder and its image count, then the viewer: both do the same thing and only the exit animation differs. - **swipe down**, or press **↓** — back to the entry screen. -The keys are there because a mouse has no swipe. A desktop browser can drag, but -the thresholds are written for a finger — a fifth of the viewport, or a flick at -a finger's speed — and neither is what a mouse produces. A key press and a swipe -resolve to the same two outcomes and run through the same code (`keyOutcome` in -`core.js`, `Viewer.commit` in `app.js`), so the two cannot drift apart. +The keys are there because a mouse has no swipe. Dragging with one works, but the +thresholds are written for a finger — a fifth of the viewport, or a flick at a +finger's speed — and neither is quite what a mouse produces. A key press and a +swipe resolve to the same two outcomes and run through the same code +(`keyOutcome` in `core.js`, `Viewer.commit` in `app.js`), so the two cannot drift +apart. + +**`draggable="false"` on the image is load-bearing.** An `` is natively +draggable, so without it a mouse press-and-move starts an HTML5 drag-and-drop, +which fires `pointercancel` and springs the image back a few pixels into the +gesture — mouse drags simply do not work. Touch never starts a native drag, +which is why this was invisible on a phone. **↑ is unbound**, but not for the reason swipe-up is: a keyboard has no system gesture to collide with. There is simply nothing for it to mean, since ← and → diff --git a/main.go b/main.go index e72215c..bd8f0f2 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "io/fs" "log" "mime" + "net" "net/http" "os" "strconv" @@ -73,9 +74,13 @@ func main() { log.Printf("listening on http://%s", displayAddr(cfg.Listen)) // Worth saying once rather than leaving it to be discovered: over plain // HTTP by IP, the browser withholds Wake Lock and Fullscreen because - // the page is not a secure context. localhost is exempt; a LAN address - // is not. - log.Printf("note: reached by LAN IP over http, the screen will sleep and fullscreen is unavailable — set tls.cert/tls.key to fix") + // the page is not a secure context. localhost is exempt, which is why + // this is not printed for a bind that nothing but loopback can reach — + // there the note would be advising a fix for a problem the deployment + // does not have. + if !loopbackOnly(cfg.Listen) { + log.Printf("note: reached by LAN IP over http, the screen will sleep and fullscreen is unavailable — set tls.cert/tls.key to fix") + } err = httpServer.ListenAndServe() } if err != nil && err != http.ErrServerClosed { @@ -90,6 +95,28 @@ func displayAddr(listen string) string { return listen } +// Whether nothing but loopback can reach this bind address — which is exactly +// when a page served over plain HTTP is still a secure context, and Wake Lock +// and Fullscreen work without a certificate. +// +// Everything uncertain answers false. A bare port listens on every interface, a +// hostname other than localhost can resolve anywhere, and an address that will +// not parse is about to fail at Listen anyway. A redundant note costs a line of +// log; a missing one sends someone hunting for why the screen keeps sleeping. +func loopbackOnly(listen string) bool { + host, _, err := net.SplitHostPort(listen) + if err != nil { + host = listen + } + if host == "localhost" { + return true + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() + } + return false +} + type server struct { cfg Config library *Library diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..a4602e5 --- /dev/null +++ b/main_test.go @@ -0,0 +1,40 @@ +package main + +import "testing" + +// The startup note about Wake Lock and Fullscreen is only true when the page is +// not a secure context, which is to say when something other than loopback can +// reach it. Getting this wrong in the safe direction prints a redundant line; +// getting it wrong the other way tells someone their screen will sleep when it +// will not, or stays silent when it will. +func TestLoopbackOnlyRecognisesEveryLoopbackBind(t *testing.T) { + cases := []struct { + listen string + want bool + }{ + {"127.0.0.1:6969", true}, + {"localhost:6969", true}, + {"[::1]:6969", true}, + // Anything in 127/8 is loopback, not just .1. + {"127.0.0.2:6969", true}, + {"127.0.0.1", true}, // no port; still unambiguously loopback + + // The default. A bare port is every interface, so a LAN IP reaches it. + {":6969", false}, + {"0.0.0.0:6969", false}, + {"[::]:6969", false}, + {"192.168.1.10:6969", false}, + {"10.0.0.23:6969", false}, + // A hostname can resolve anywhere, including to a LAN address. + {"photos.apps.example.com:6969", false}, + // Nonsense; Listen will reject it moments later. + {"", false}, + {"not an address", false}, + } + + for _, tc := range cases { + if got := loopbackOnly(tc.listen); got != tc.want { + t.Errorf("loopbackOnly(%q) = %v, want %v", tc.listen, got, tc.want) + } + } +} diff --git a/static/index.html b/static/index.html index b8a099c..de66228 100644 --- a/static/index.html +++ b/static/index.html @@ -60,7 +60,12 @@

Random Image