Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>` 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
Expand Down
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img>` 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 →
Expand Down
33 changes: 30 additions & 3 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io/fs"
"log"
"mime"
"net"
"net/http"
"os"
"strconv"
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
40 changes: 40 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
7 changes: 6 additions & 1 deletion static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,12 @@ <h1>Random Image</h1>
</main>

<div id="viewer" hidden>
<img id="photo" alt="" hidden>
<!-- draggable="false" is what makes a mouse drag work at all. An <img> is
natively draggable, so a press-and-move on a desktop starts an HTML5
drag-and-drop, which fires pointercancel and springs the image back a few
pixels in. Touch never starts a native drag, which is why phones were
unaffected and this went unnoticed. -->
<img id="photo" alt="" draggable="false" hidden>
<div id="viewer-spinner" class="spinner" hidden></div>
<div id="viewer-error" class="viewer-error" hidden>
<svg viewBox="0 0 24 24" aria-hidden="true" class="warning">
Expand Down
5 changes: 5 additions & 0 deletions static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,11 @@ h1 {
position: absolute;
object-fit: contain;
will-change: transform;
/* Belt and braces with draggable="false" in the markup: Safari has long
needed the property as well as the attribute to stop a mouse drag on an
image becoming a native drag-and-drop, which cancels the pointer stream
mid-swipe. There is no unprefixed form — it is not a standard property. */
-webkit-user-drag: none;
}
#photo[hidden] { display: none; }

Expand Down
Loading