A Go server plus a static client. It shows one random image at a time from a configured folder, fitted to the screen, no chrome. That is the whole app.
Adapted from RandomImageViewer, a SwiftUI iOS app that did the same thing from a user-chosen folder.
The folder is configured on the server, not chosen in the browser. That one decision is what makes this work everywhere: a browser-side folder needs the File System Access API, which is Chromium-desktop only and absent from every browser on iOS. With a server-side path, this runs in Safari, on a phone, and as an installable PWA.
cp config.example.json config.json # then edit "folder"
go build -o randomimageviewer .
./randomimageviewer -config config.jsonGo 1.22+. One dependency, golang.org/x/image, for the resampling kernel. The
client is embedded in the binary, so the built binary is the whole deployment —
no runtime, no asset directory, no npm.
go test ./... # server: listing filters, natural sort, EXIF, caps, config
node --test # client: caps, fitting, placement, gestures, random drawstatic/apple-touch-icon.png is generated from static/icon.svg, not drawn
separately — iOS ignores SVG icons for "Add to Home Screen" and falls back to a
screenshot of the page, so the PNG is the only thing that yields a real icon.
Regenerate it after editing the SVG:
magick -background black -density 576 static/icon.svg -resize 180x180 \
-alpha remove -alpha off -colorspace sRGB -strip PNG24:static/apple-touch-icon.png-alpha remove matters: iOS composites a transparent icon onto black and the
artwork is already black, so an icon with alpha comes out as a dark smear.
Nothing in the dependency tree uses cgo, so the binary links statically and the
deployment is one file — no libc version to match, no runtime, no asset
directory. /usr/local/sbin rather than /opt, because /opt is for packages
that bring their own directory tree and this one has nothing to put in it.
make deb # writes dist/randomimageviewer_<version>_amd64.deb
make deb ARCH=arm64
scp dist/randomimageviewer_*.deb server:
ssh server 'sudo apt install ./randomimageviewer_0.1.0_amd64.deb'Packaging needs nothing installed beyond the Go toolchain: nfpm is pinned and
run through go run, and it cross-builds a .deb from any host, macOS
included. Version comes from the nearest vX.Y.Z tag, or override it with
make deb VERSION=1.2.3 — Debian versions have to start with a digit, so there
is no falling back to a commit hash.
The package creates the service user, installs the unit, and enables it without starting it, since the shipped config names a folder that will not exist. Two steps are left:
sudo nano /etc/randomimageviewer/config.json # set "folder"
sudo systemctl start randomimageviewer/etc/randomimageviewer/config.json is registered as a dpkg conffile, which
is most of the reason to package at all: upgrades never silently overwrite a
config naming this machine's photo folder. apt remove stops and disables the
service; apt purge additionally removes the cache directory and the service
user. The image folder is never touched.
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' \
-o randomimageviewer-linux-amd64 .
sudo install -o root -g root -m 0755 randomimageviewer-linux-amd64 \
/usr/local/sbin/randomimageviewer
sudo useradd --system --no-create-home --shell /usr/sbin/nologin randomimageviewer
sudo install -d -o root -g randomimageviewer -m 0750 /etc/randomimageviewer
sudo install -o root -g randomimageviewer -m 0640 deploy/config.default.json \
/etc/randomimageviewer/config.json
sudo install -m 0644 deploy/randomimageviewer.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now randomimageviewercacheDir cannot be left empty under systemd. Empty means "ask
os.UserCacheDir()", which reads $HOME — and a service running as a
home-less system user has none, so the server exits at startup with
locate cache directory: $HOME is not defined and Restart=on-failure loops on
it. Set it to /var/cache/randomimageviewer, which is what the unit's
CacheDirectory= creates and chowns.
The unit sandboxes tightly, since the process reads one folder and writes one cache directory and needs nothing else. It names no machine-specific path, so site-specific settings go in a drop-in rather than in the unit itself:
# /etc/systemd/system/randomimageviewer.service.d/override.conf
[Unit]
# Only if the images sit on a separate mount — stops the server starting
# against an empty mountpoint and reporting the folder as missing.
RequiresMountsFor=/srv/photos
[Service]
# Only if the images live under /home, which ProtectHome=yes otherwise hides.
ProtectHome=read-onlyProtectSystem=strict already makes the whole hierarchy read-only, so the image
folder needs no ReadOnlyPaths entry — but check the service user can actually
read it, which no amount of sandboxing grants:
sudo -u randomimageviewer ls /srv/photos
journalctl -u randomimageviewer -fNot optional in practice. Reached by anything other than localhost over plain
HTTP the page is not a secure context, so Wake Lock and Fullscreen are both
refused — the screen sleeps partway through a slideshow and the first image
cannot go fullscreen. There are two shapes, and the right one depends on whether
this is the only application on the host.
Point the config at a cert and key. config.go rejects one without the other,
so a half-configured pair fails at startup rather than silently serving plain
HTTP:
Key at 0640 root:randomimageviewer. HTTP/2 comes free — Go negotiates it on
any TLS listener, which suits a viewer that fires two requests per image.
The certificate is read exactly once, at startup. ListenAndServeTLS opens
the files and nothing reloads them, so a renewal at day 60 keeps serving the old
certificate until the service restarts — and you find out at day 90 when the
browser starts refusing. Renewal has to restart it:
# /etc/letsencrypt/renewal-hooks/deploy/randomimageviewer.sh
#!/bin/sh
install -o root -g randomimageviewer -m 0644 \
"$RENEWED_LINEAGE/fullchain.pem" /etc/randomimageviewer/tls.crt
install -o root -g randomimageviewer -m 0640 \
"$RENEWED_LINEAGE/privkey.pem" /etc/randomimageviewer/tls.key
systemctl restart randomimageviewerCopying rather than symlinking into /etc/letsencrypt/live/ is deliberate:
/etc/letsencrypt/archive is 0700 root, so the service user cannot follow the
symlink chain. Restarting is cheap — the derivative cache is on disk and
survives it.
Once more than one service on the host needs TLS, handing each of them the same private key stops being sensible. Terminate once and let this bind loopback:
"listen": "127.0.0.1:6969",
"tls": { "cert": "", "key": "" }The application never sees the key, the reload-on-renewal problem above stops being this program's problem, and services get names instead of port numbers. Secure context is judged on the browser-facing origin, so Wake Lock and Fullscreen still work — the plain-HTTP hop never leaves the machine.
Check the bind actually moved. A viewer still on *:6969 stays reachable over
plain HTTP alongside the TLS one, and a phone with a bookmark will go on using
it:
ss -ltnp | grep 6969 # want 127.0.0.1:6969, not *:6969A certificate binds to a hostname; there is no port in one, so a single certificate covers every service on the host whatever port each listens on.
Two constraints worth knowing before issuing. A publicly-trusted certificate
cannot be had for a made-up TLD — .lan, .local, .internal are not
delegated, so no CA can validate them and none will issue. Use a name under a
domain you own, resolved internally by your own resolver. And the DNS-01
challenge validates a TXT record at _acme-challenge.<name>, which means the
host itself needs no public A record and never has to be internet-facing.
Nothing has to reach port 80 either.
A wildcard is worth preferring. Every issued certificate is published to
Certificate Transparency, so naming hosts individually publishes an inventory of
them; *.apps.example.com publishes only the wildcard, and covers every service
added later without reissuing. Note that a wildcard matches exactly one label —
photos.apps.example.com yes, a.b.apps.example.com no.
Only publicly-trusted certificates avoid the client-side trust dance. A private CA works, but iOS needs the root installed and separately enabled under General → About → Certificate Trust Settings, which is a different screen and the step usually missed — on every device, forever.
What the above looks like in practice, with the parts that are easy to get wrong
called out. Substitute your own domain; apps.example.com is the internal
subdomain and 10.0.0.23 the host.
The name resolves privately and does not exist publicly. In unbound:
private-domain: "apps.example.com"
local-zone: "apps.example.com." transparent
local-data: "photos.apps.example.com. IN A 10.0.0.23"
transparent, not static. A static zone answers NXDOMAIN for every name it
holds no data for, and that includes _acme-challenge.… — which breaks issuance
in a way nothing in the logs attributes to your own resolver. private-domain is
needed because private-address: 10.0.0.0/8 otherwise strips RFC1918 answers
for public names as rebinding protection, and that protection is worth keeping
for everything else. No local-data-ptr: the address already reverses to the
machine's own name, and a second PTR only makes reverse lookups ambiguous.
DNS providers are Caddy plugins, so the stock binary cannot solve DNS-01:
caddy add-package github.com/caddy-dns/godaddy
caddy list-modules | grep godaddy # dns.providers.godaddyadd-package swaps the binary in place and leaves the apt packaging intact. An
xcaddy build works too, but then apt-mark hold caddy, or the next upgrade
restores a binary with no provider module and renewals stop silently.
The API credential comes from the unit, not the Caddyfile, so it is not sitting in a world-readable config:
# /etc/systemd/system/caddy.service.d/override.conf
[Service]
EnvironmentFile=/etc/caddy/caddy.env# /etc/caddy/caddy.env — 0640 root:caddy. GoDaddy wants key and secret as one
# colon-joined value.
GODADDY_API_TOKEN=key:secret
photos.apps.example.com {
tls {
dns godaddy {env.GODADDY_API_TOKEN}
resolvers 1.1.1.1 8.8.8.8
propagation_delay 5m
propagation_timeout 20m
}
reverse_proxy 127.0.0.1:6969
}
Each of the following cost time to work out:
Caddy substitutes an empty string for an unset {env.*} without complaining.
Forget the drop-in and the provider sends an empty Authorization header, which
GoDaddy answers with a bare 401 and no body — indistinguishable at a glance
from a bad key. The packaged unit's ExecStart carries --environ, which dumps
the whole environment at startup, so journalctl -u caddy | grep GODADDY is the
first thing to check when a placeholder misbehaves.
resolvers is load-bearing on this host. The propagation check otherwise
uses the system resolver, which is the unbound above serving its own split-horizon
view of the domain — so Caddy would be asking the one resolver on the network
guaranteed not to know what the CA sees.
propagation_delay has to be in minutes. GoDaddy enforces a 600-second
minimum TTL and syncs its anycast fleet on roughly that cadence; 15 seconds fails
every time. A DNS problem: NXDOMAIN prefixed During secondary validation:
is the useful diagnostic — it means Let's Encrypt's primary vantage point found
the record and a second one had not seen it yet, so the record is being created
correctly and only the wait is too short.
Failed attempts are self-sustaining. The last field of the zone's SOA is
the negative-cache TTL — 3600 at GoDaddy, so an hour. Caddy deletes the TXT
record after each failure, so every attempt that runs while a resolver has cached
NXDOMAIN re-poisons it for another hour. Point acme_ca at
https://acme-staging-v02.api.letsencrypt.org/directory while sorting this out:
production allows five failed validations per hostname per hour, and a tight
retry loop burns through that before you have read the first error.
Reload for a Caddyfile change; restart for anything in the unit.
systemctl reload caddy runs caddy reload and swaps the config gracefully, but
EnvironmentFile is read once at process start — so a credential change needs
systemctl daemon-reload && systemctl restart caddy. Reloading after adding the
drop-in reproduces the original 401 exactly.
Renewal is the one path that never gets tested. It fires unattended in 60 days and depends on that propagation window still holding. Prove it from cold rather than finding out from a browser warning:
systemctl stop caddy
rm -rf /var/lib/caddy/.local/share/caddy/certificates/acme-v02*
systemctl start caddyIf GoDaddy proves too slow to leave in the renewal path, CNAME the challenge
name at a zone with faster propagation — acme-dns
or a free Cloudflare zone — and set dns_challenge_override_domain. One manual
record per name, set once, and the registrar's API stops mattering.
GET /api/images returns the manifest; the client picks a random image from it
and requests GET /api/image/<id>?w=<cap>. The server decodes, applies EXIF
orientation, resizes to the cap, re-encodes, and caches the result on disk.
Resizing server-side is the direct analogue of what CGImageSourceCreateThumbnail
did on iOS, just relocated — and it is strictly better here, because it also
means a 12 MP original never crosses the network. A cold render of a 4000×3000
JPEG takes ~480 ms; the cached hit is ~0.5 ms.
Supported formats are JPEG and PNG. Adding more is a matter of an entry in
imageExtensions plus a decoder Go can register — but note the iOS app's HEIC
and TIFF support does not carry over, since neither ships in the standard
library.
An entry screen naming the folder and its image count, then the viewer:
- swipe left or right, or press ← or → — another image at random. Direction carries no meaning, since the next image is random either way, so 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. 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 → already say "another image".
Swiping up is deliberately unused, and should stay that way. On a phone an
upward drag near the bottom edge belongs to the system, so anything bound to it
intermittently dismisses the app instead. Only downward travel is read, which is
why swipeOutcome tests travel.y > … rather than a magnitude.
A file that lists but will not render — a truncated download, a mislabelled file — is marked broken for the session and skipped, and another image is drawn immediately, so one bad file cannot strand the viewer. Only a 4xx does this: a 5xx or a dead network would fail identically for every other file, so that case stops rather than grinding through the whole folder. "Rescan Folder" forgives the broken set.
The entry screen is what makes the first image fullscreen. The Fullscreen API
only works inside a user gesture, so without a button to click there is no way to
enter fullscreen before an image is already on screen. That is a real constraint,
not a stylistic preference — going straight to an image means the first one
cannot be fullscreen. Installing the page as a PWA sidesteps it entirely; the
manifest declares "display": "fullscreen".
Wake Lock and Fullscreen need a secure context. http://localhost counts;
http://192.168.1.x does not. Both fail soft, so the viewer works either way —
see TLS for the two ways to give it one.
Screen brightness is gone. The iOS app forced full brightness in the viewer;
no web API exposes brightness at any privilege level. ScreenBoost in app.js
is the surviving half.
The client never names a file. IDs are hashes the server minted and still holds in its listing, and they are never joined onto a path. There is no string a client can send that reaches the filesystem, which is what makes traversal impossible rather than merely filtered.
?w= is clamped server-side to the same 128 px bucket grid the client uses.
Without that, ?w=999999 is a resize to nine hundred thousand pixels on the long
edge. metrics.go and core.js hold the same rules and share an expectation
table across their two test suites.
| File | Adapted from |
|---|---|
library.go |
FolderStore — minus the bookmark, scope and adopt/release cycle |
images.go |
ImageLoader — the disk cache replaces the two NSCache tiers |
metrics.go |
DisplayMetrics — also doubles as input validation |
exif.go |
kCGImageSourceCreateThumbnailWithTransform, which Go has no equivalent of |
naturalsort.go |
localizedStandardCompare |
static/core.js |
The pure arithmetic of DisplayMetrics and ViewerView |
static/app.js |
ContentView, EntryView, ViewerView |
Behaviour carried over from the iOS app and worth not undoing: no zoom, no pan, no folder browser, no thumbnails or grid, no captions or metadata, and one folder only. The home indicator is deliberately not treated as an obstruction — it is a translucent overlay, not a cutout, so an image may sit under it, and counting it in the inset would shrink every landscape image for no visual gain.
- No service worker, so this does not run offline.
main is protected: direct pushes are rejected and changes land through a pull
request that CI has passed. Set the local half up once per clone, which makes
git refuse the push before it reaches GitHub:
git config core.hooksPath .githooksCI (.github/workflows/ci.yml) runs what make test runs, plus gofmt -l,
go vet and a packaging build. Everything it does works locally:
make test # go test ./... and node --test
gofmt -l . # prints nothing when clean
go vet ./...
make deb # proves nfpm.yaml still packagesThe version in the package comes from git describe, so the tag is the first
step rather than the last — build before tagging and the .deb is named after
the previous release.
git checkout main && git pull # from a clean tree: --dirty
# would land in the version
git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin vX.Y.Z
make deb # amd64
make deb ARCH=arm64 # and arm64
( cd dist && shasum -a 256 *.deb > SHA256SUMS )
gh release create vX.Y.Z --prerelease \
--title "vX.Y.Z" --notes "…" \
dist/randomimageviewer_X.Y.Z_amd64.deb \
dist/randomimageviewer_X.Y.Z_arm64.deb \
dist/SHA256SUMSBoth architectures, even though the deployment this was written for is amd64:
make deb alone quietly produces an amd64-only release, and a Pi is an obvious
place to run this.
Drop --prerelease once a version has run somewhere other than the machine it
was written on.
MIT — see LICENSE.