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 @@