diff --git a/CHANGELOG.md b/CHANGELOG.md
index c746018..4cf183d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,7 +31,10 @@ Add to `## [Unreleased]` as you go; rename it when you tag.
`sudo syncphony-box update` runs it by hand.
- `/v1/info` reports uptime and the last update (when, and whether it worked) for the
room's Screens list.
-- The Wi-Fi setup hotspot for a box with no network.
+- The Wi-Fi setup hotspot for a box with no network. The setup screen
+ counts down to it, says why when it can't start, and has a **Start
+ setup Wi-Fi now** button. **Choose Wi-Fi on this screen** sets the
+ Wi-Fi from the TV with a keyboard, no phone needed.
- HDMI-CEC: the TV wakes and switches when music starts; the remote's
keys drive the box; optional standby when the room is idle.
- Watchdogs and self-healing: hardware watchdog, kiosk watchdog,
@@ -42,6 +45,16 @@ Add to `## [Unreleased]` as you go; rename it when you tag.
- Audio output selection (`audio=`) on PipeWire.
- `syncphony.txt` boot config file and the cage + Chromium kiosk session.
+### Fixed
+- x86 live session: booting from a stick without a `syncphony-data`
+ partition no longer stalls on a missing `/data` device and then fails
+ SSH and the kiosk (`mkdir: cannot create directory '/data/chromium'`).
+ `/data` is the stick's partition when there is one, and RAM otherwise.
+- x86: the setup hotspot never appeared, because the image had no
+ `wpasupplicant` (NetworkManager only recommends it), so NetworkManager
+ couldn't scan or run an access point. It also gains `wireless-regdb`
+ and MediaTek and Broadcom Wi-Fi firmware.
+
### Changed
- The image is smaller: documentation, man pages and translations are
stripped (copyright files stay).
diff --git a/README.md b/README.md
index b94f396..c584c74 100644
--- a/README.md
+++ b/README.md
@@ -207,9 +207,15 @@ and serves the box's own screens:
the TV says why. A new box starts the hotspot 60 s after boot. A box
that has been online before shows the offline screen first and only
falls back to the hotspot after 5 minutes without a network, so a
- router reboot never puts it in setup mode. Imager's Wi-Fi settings
- still work and are the fast path
+ router reboot never puts it in setup mode. Until the hotspot starts,
+ the setup screen counts down to it and says why if it can't start.
+ Imager's Wi-Fi settings still work and are the fast path
([ADR 0010](docs/adr/0010-wifi-setup-hotspot.md)).
+- **Wi-Fi from the TV itself.** With a mouse, a keyboard or the remote,
+ the setup screen's **Start setup Wi-Fi now** skips the wait. **Choose
+ Wi-Fi on this screen** (also on the hotspot screen) lists the networks
+ in range on the TV: pick one, type its password, and the box joins it
+ with no phone needed.
Logs go to journald: `journalctl -u boxd -u syncphony-kiosk`.
diff --git a/boxd/internal/daemon/bridge.go b/boxd/internal/daemon/bridge.go
index 49c4545..c51815a 100644
--- a/boxd/internal/daemon/bridge.go
+++ b/boxd/internal/daemon/bridge.go
@@ -35,6 +35,13 @@ func (d *Daemon) bridgeMux() *http.ServeMux {
mux.HandleFunc("GET /start", d.handleStartScreen)
mux.HandleFunc("GET /storage", d.handleStorageScreen)
mux.HandleFunc("GET /hotspot", d.handleHotspotScreen)
+ // The TV's own Wi-Fi controls (the setup and hotspot screens' buttons,
+ // and the on-screen network picker).
+ mux.HandleFunc("GET /wifi", screenOnly(d.handleWiFiScreen))
+ mux.HandleFunc("POST /wifi/hotspot", screenOnly(d.handleWiFiHotspotNow))
+ mux.HandleFunc("POST /wifi/scan", screenOnly(d.handleWiFiScan))
+ mux.HandleFunc("POST /wifi/join", screenOnly(d.handleWiFiJoin))
+ mux.HandleFunc("POST /wifi/hold", screenOnly(d.handleWiFiHold))
return mux
}
diff --git a/boxd/internal/daemon/hotspot.go b/boxd/internal/daemon/hotspot.go
index 52fceec..a2c001e 100644
--- a/boxd/internal/daemon/hotspot.go
+++ b/boxd/internal/daemon/hotspot.go
@@ -1,6 +1,8 @@
package daemon
import (
+ "encoding/json"
+ "errors"
"fmt"
"net"
"net/http"
@@ -40,6 +42,11 @@ const (
recheckEvery = 5 * time.Minute
portalQuiet = 2 * time.Minute
+ // tvQuiet: someone is choosing a network on the TV's own Wi-Fi screen
+ // (it pings /wifi/hold while in use). The hotspot doesn't start under
+ // them; it needs the radio the screen is scanning with.
+ tvQuiet = 30 * time.Second
+
dataPersist = "/usr/lib/syncphony-box/data-persist"
)
@@ -87,6 +94,9 @@ type netState struct {
offlineSince time.Time // no connection continuously since (zero = online)
seenOnline bool // this box has been online before (persisted)
lastPortal time.Time // last request to the captive portal
+ lastTV time.Time // last use of the TV's own Wi-Fi screen
+ deviceErr string // why there is no Wi-Fi device (yet)
+ startErr string // why the hotspot couldn't start, last time it tried
}
// netView is the locked snapshot decideNet runs on.
@@ -99,6 +109,7 @@ type netView struct {
offlineSince time.Time
apSince time.Time
lastPortal time.Time
+ lastTV time.Time
savedWiFi bool
}
@@ -128,19 +139,25 @@ func decideNet(v netView) (netAction, string) {
}
return netNothing, ""
}
- if v.online || v.offlineSince.IsZero() {
+ if v.online || v.offlineSince.IsZero() || v.now.Sub(v.lastTV) < tvQuiet {
return netNothing, ""
}
- grace := bootGrace
- if v.seenOnline {
- grace = lostGrace
- }
+ grace := hotspotGrace(v.seenOnline)
if v.now.Sub(v.offlineSince) >= grace {
return netStartAP, fmt.Sprintf("no Ethernet or Wi-Fi connection for %s", grace)
}
return netNothing, ""
}
+// hotspotGrace is how long a box without a connection waits before it
+// starts the hotspot.
+func hotspotGrace(seenOnline bool) time.Duration {
+ if seenOnline {
+ return lostGrace
+ }
+ return bootGrace
+}
+
// boxdDir is boxd's own state directory on /data (docs/persistent-paths.md);
// SB_BOXD_DIR moves it in dev and tests.
func boxdDir() string {
@@ -187,10 +204,14 @@ func (d *Daemon) netWatch() {
logged := false
for {
dev, err := d.wifi.Device()
+ d.mu.Lock()
+ if err == nil {
+ d.net.device, d.net.deviceErr = dev, ""
+ } else {
+ d.net.deviceErr = err.Error()
+ }
+ d.mu.Unlock()
if err == nil {
- d.mu.Lock()
- d.net.device = dev
- d.mu.Unlock()
break
}
if !logged {
@@ -236,6 +257,7 @@ func (d *Daemon) netTick(now time.Time) {
offlineSince: d.net.offlineSince,
apSince: d.net.apSince,
lastPortal: d.net.lastPortal,
+ lastTV: d.net.lastTV,
}
d.mu.Unlock()
if first {
@@ -269,12 +291,17 @@ func (d *Daemon) startHotspot(reason string) {
d.log.Printf("boxd: wifi: cannot start the setup hotspot: %v", err)
d.mu.Lock()
d.net.phase = phaseNone
+ d.net.startErr = err.Error() // the TV says why
d.net.offlineSince = time.Now() // re-arm the grace instead of retrying every tick
+ if nets != nil {
+ d.net.networks = nets // the TV's own Wi-Fi screen can still use them
+ }
d.mu.Unlock()
return
}
d.mu.Lock()
d.net.phase = phaseHotspot
+ d.net.startErr = ""
d.net.apSSID = ssid
d.net.apSince = time.Now()
if nets != nil {
@@ -319,14 +346,18 @@ func (d *Daemon) recheck() {
d.startHotspot("no saved network in range")
}
-// joinWiFi is the save from the portal, run after the phone got its
-// answer: give the radio back, join, and on success persist the network
-// and (if the form had it) the server URL. On failure the hotspot comes
-// back and the TV shows why.
-func (d *Daemon) joinWiFi(ssid, password string, hidden bool, serverURL, name string) {
+// joinWiFi is the save from the portal (or the TV's own Wi-Fi screen),
+// run after the asker got its answer: give the radio back, join, and on
+// success persist the network and (if the form had it) the server URL.
+// On failure from the portal the hotspot comes back and the TV shows
+// why; from the TV, its Wi-Fi screen shows why and the hotspot keeps to
+// its usual timing.
+func (d *Daemon) joinWiFi(ssid, password string, hidden bool, serverURL, name string, fromTV bool) {
d.netOp.Lock()
defer d.netOp.Unlock()
- time.Sleep(joinDelay)
+ if !fromTV {
+ time.Sleep(joinDelay)
+ }
d.mu.Lock()
dev := d.net.device
@@ -340,8 +371,13 @@ func (d *Daemon) joinWiFi(ssid, password string, hidden bool, serverURL, name st
d.mu.Lock()
d.net.joining = ""
d.net.lastError = capitalize(err.Error())
+ if fromTV {
+ d.net.lastTV = time.Now() // the screen is still in use: retry, not hotspot
+ }
d.mu.Unlock()
- d.startHotspot("the join failed")
+ if !fromTV {
+ d.startHotspot("the join failed")
+ }
return
}
@@ -440,35 +476,18 @@ func (d *Daemon) handleWiFiSave(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
writePage(w, wifiFormHTML(d.wifiForm(msg)))
}
- ssid := strings.TrimSpace(r.PostFormValue("other_ssid"))
- if ssid == "" {
- ssid = r.PostFormValue("ssid")
- }
- password := r.PostFormValue("password") // spaces are legal in a passphrase
+ ssid, password := joinRequest(r)
serverURL := strings.TrimSpace(r.PostFormValue("server_url"))
name := strings.TrimSpace(r.PostFormValue("name"))
- if ssid == "" {
- fail("Pick your Wi-Fi network, or type its name.")
+ known, problem := d.checkJoin(ssid, password)
+ if problem != "" {
+ fail(problem)
return
}
d.mu.Lock()
- known, secure := false, false
- for _, nw := range d.net.networks {
- if nw.SSID == ssid {
- known, secure = true, nw.Secure
- }
- }
askServer := d.cfg.ServerURL == ""
d.mu.Unlock()
- if secure && password == "" {
- fail(ssid + " needs its Wi-Fi password.")
- return
- }
- if password != "" && (len(password) < 8 || len(password) > 64) {
- fail("Wi-Fi passwords are 8 to 63 characters — check it and try again.")
- return
- }
if !askServer {
serverURL, name = "", ""
}
@@ -493,7 +512,42 @@ func (d *Daemon) handleWiFiSave(w http.ResponseWriter, r *http.Request) {
d.log.Printf("boxd: wifi: join %s requested from %s", ssid, r.RemoteAddr)
writePage(w, joiningPageHTML(ssid, apSSID))
- go d.joinWiFi(ssid, password, !known, serverURL, name)
+ go d.joinWiFi(ssid, password, !known, serverURL, name, false)
+}
+
+// joinRequest reads the network and password from a join form: a typed
+// name (a network that isn't listed) wins over the picked one.
+func joinRequest(r *http.Request) (ssid, password string) {
+ ssid = strings.TrimSpace(r.PostFormValue("other_ssid"))
+ if ssid == "" {
+ ssid = r.PostFormValue("ssid")
+ }
+ return ssid, r.PostFormValue("password") // spaces are legal in a passphrase
+}
+
+// checkJoin vets a join against the last scan before anything changes,
+// so the asker sees the mistake while still connected. known says the
+// network was in the scan (one that wasn't is joined as hidden); problem
+// is what to tell them, empty when the join can go ahead.
+func (d *Daemon) checkJoin(ssid, password string) (known bool, problem string) {
+ if ssid == "" {
+ return false, "Pick your Wi-Fi network, or type its name."
+ }
+ d.mu.Lock()
+ secure := false
+ for _, nw := range d.net.networks {
+ if nw.SSID == ssid {
+ known, secure = true, nw.Secure
+ }
+ }
+ d.mu.Unlock()
+ if secure && password == "" {
+ return known, ssid + " needs its Wi-Fi password."
+ }
+ if password != "" && (len(password) < 8 || len(password) > 64) {
+ return known, "Wi-Fi passwords are 8 to 63 characters — check it and try again."
+ }
+ return known, ""
}
// handleHotspotScreen is the TV side: join the box's network (QR), then
@@ -524,15 +578,192 @@ func (d *Daemon) handleHotspotScreen(w http.ResponseWriter, r *http.Request) {
writePage(w, hotspotScreenHTML(data))
}
-// wifiStatus is the hotspot's part of /status.
+// wifiStatus is the hotspot's part of /status. Beyond the phase: whether
+// the box has a Wi-Fi device at all (and why not), why the hotspot last
+// failed to start, and — while the box is offline and waiting — how many
+// seconds until it starts (hotspotIn), for the setup screen's countdown.
func (d *Daemon) wifiStatus() map[string]any {
+ return d.wifiStatusAt(time.Now())
+}
+
+func (d *Daemon) wifiStatusAt(now time.Time) map[string]any {
d.mu.Lock()
defer d.mu.Unlock()
- return map[string]any{
- "phase": string(d.net.phase),
- "hotspot": d.net.apSSID,
- "joining": d.net.joining,
- "error": d.net.lastError,
- "notice": d.net.notice,
+ st := map[string]any{
+ "phase": string(d.net.phase),
+ "hotspot": d.net.apSSID,
+ "joining": d.net.joining,
+ "error": d.net.lastError,
+ "notice": d.net.notice,
+ "device": d.net.device != "",
+ "startError": d.net.startErr,
+ }
+ if d.net.device == "" {
+ st["unavailable"] = d.net.deviceErr
+ }
+ if d.net.device != "" && d.net.phase == phaseNone && d.net.joining == "" && !d.net.offlineSince.IsZero() {
+ left := hotspotGrace(d.net.seenOnline) - now.Sub(d.net.offlineSince)
+ st["hotspotIn"] = max(0, int((left+time.Second-1)/time.Second))
+ }
+ return st
+}
+
+// --- the TV's own Wi-Fi controls ------------------------------------------------
+//
+// For a box with a keyboard or a mouse (a PC, or a Pi with one plugged
+// in), or a remote for the buttons: start the hotspot now instead of
+// waiting out the grace, or skip the phone and pick the network on the
+// TV. Served on loopback only, to the kiosk's own pages (screenOnly).
+
+var errNetBusy = errors.New("the Wi-Fi is busy (a join or a check is running); try again in a moment")
+
+// startHotspotNow is the setup screen's "Start setup Wi-Fi now" button:
+// the hotspot without the grace. It returns once the request is vetted;
+// the scan and the access point come up in the background, and the
+// screen follows through /status.
+func (d *Daemon) startHotspotNow() error {
+ d.mu.Lock()
+ dev, phase, joining := d.net.device, d.net.phase, d.net.joining
+ d.mu.Unlock()
+ switch {
+ case dev == "":
+ return errors.New("this box has no Wi-Fi it can use")
+ case phase != phaseNone || joining != "":
+ return nil // already up, or about to be
+ }
+ if online, _ := d.wifi.Online(); online {
+ return errors.New("this box is already connected")
}
+ d.mu.Lock()
+ d.net.startErr = "" // this attempt's outcome, not the last one's
+ d.mu.Unlock()
+ go func() {
+ d.netOp.Lock()
+ defer d.netOp.Unlock()
+ d.mu.Lock()
+ busy := d.net.phase != phaseNone || d.net.joining != ""
+ d.mu.Unlock()
+ if !busy {
+ d.startHotspot("started from the TV")
+ }
+ }()
+ return nil
+}
+
+// scanForTV is the TV Wi-Fi screen's list: a fresh scan, which needs the
+// radio, so a running hotspot gives it up (whoever is at the TV chose to
+// set up here). It holds the hotspot off for tvQuiet from now.
+func (d *Daemon) scanForTV() ([]wifi.Network, error) {
+ if !d.netOp.TryLock() {
+ return nil, errNetBusy
+ }
+ defer d.netOp.Unlock()
+ d.mu.Lock()
+ dev, phase := d.net.device, d.net.phase
+ d.net.lastTV = time.Now()
+ d.mu.Unlock()
+ if dev == "" {
+ return nil, errors.New("this box has no Wi-Fi it can use")
+ }
+ if phase == phaseHotspot {
+ d.stopHotspot("Wi-Fi is being set up on the TV")
+ }
+ nets, err := d.wifi.Scan(dev)
+ if err != nil {
+ d.log.Printf("boxd: wifi: scan from the TV: %v", err)
+ return nil, fmt.Errorf("couldn't look for networks: %w", err)
+ }
+ d.mu.Lock()
+ d.net.networks = nets
+ d.mu.Unlock()
+ return nets, nil
+}
+
+func (d *Daemon) handleWiFiScreen(w http.ResponseWriter, r *http.Request) {
+ d.holdForTV()
+ writePage(w, wifiScreenHTML())
+}
+
+func (d *Daemon) holdForTV() {
+ d.mu.Lock()
+ d.net.lastTV = time.Now()
+ d.mu.Unlock()
+}
+
+func (d *Daemon) handleWiFiHold(w http.ResponseWriter, r *http.Request) {
+ d.holdForTV()
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (d *Daemon) handleWiFiHotspotNow(w http.ResponseWriter, r *http.Request) {
+ if err := d.startHotspotNow(); err != nil {
+ writeJSON(w, http.StatusConflict, map[string]any{"error": capitalize(err.Error()) + "."})
+ return
+ }
+ d.log.Printf("boxd: wifi: setup hotspot requested from the TV")
+ writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
+}
+
+func (d *Daemon) handleWiFiScan(w http.ResponseWriter, r *http.Request) {
+ nets, err := d.scanForTV()
+ if err != nil {
+ writeJSON(w, http.StatusConflict, map[string]any{"error": capitalize(err.Error())})
+ return
+ }
+ list := make([]map[string]any, 0, len(nets))
+ for _, nw := range nets {
+ list = append(list, map[string]any{"ssid": nw.SSID, "signal": nw.Signal, "secure": nw.Secure})
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"networks": list})
+}
+
+// handleWiFiJoin is the TV Wi-Fi screen's Connect: the same checks as
+// the portal's form, then the join in the background (the screen follows
+// it through /status).
+func (d *Daemon) handleWiFiJoin(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]any{"error": "Bad request."})
+ return
+ }
+ ssid, password := joinRequest(r)
+ known, problem := d.checkJoin(ssid, password)
+ if problem != "" {
+ writeJSON(w, http.StatusBadRequest, map[string]any{"error": problem})
+ return
+ }
+ d.mu.Lock()
+ if busy := d.net.joining; busy != "" {
+ d.mu.Unlock()
+ writeJSON(w, http.StatusConflict, map[string]any{"error": "Already joining " + busy + "."})
+ return
+ }
+ d.net.joining = ssid
+ d.net.lastError, d.net.notice = "", ""
+ d.net.lastTV = time.Now()
+ d.mu.Unlock()
+ d.log.Printf("boxd: wifi: join %s requested from the TV", ssid)
+ writeJSON(w, http.StatusAccepted, map[string]any{"ok": true})
+ go d.joinWiFi(ssid, password, !known, "", "", true)
+}
+
+// screenOnly admits only the kiosk's own pages: addressed as loopback
+// (no DNS rebinding) and, when the browser says where the request comes
+// from, from boxd's own origin — not the server's page, not anything else.
+func screenOnly(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ origin := r.Header.Get("Origin")
+ own := strings.TrimPrefix(origin, "http://")
+ if !allowedHost(r.Host) || (origin != "" && (own == origin || !allowedHost(own))) {
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ next(w, r)
+ }
+}
+
+func writeJSON(w http.ResponseWriter, code int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(code)
+ json.NewEncoder(w).Encode(v)
}
diff --git a/boxd/internal/daemon/hotspot_test.go b/boxd/internal/daemon/hotspot_test.go
index d6c777b..b34f034 100644
--- a/boxd/internal/daemon/hotspot_test.go
+++ b/boxd/internal/daemon/hotspot_test.go
@@ -22,6 +22,7 @@ type fakeNet struct {
saved bool
nets []wifi.Network
connectErr error
+ startErr error
apUp bool
calls []string
// onConnect runs inside Connect (a join can bring the box online).
@@ -54,6 +55,9 @@ func (f *fakeNet) StartAP(dev, ssid string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.log("start " + ssid)
+ if f.startErr != nil {
+ return f.startErr
+ }
f.apUp = true
return nil
}
@@ -427,3 +431,154 @@ func TestScreensFollowTheHotspot(t *testing.T) {
t.Errorf("/status = %s", st)
}
}
+
+// --- the TV's own Wi-Fi controls ---------------------------------------------------
+
+func TestDecideNetWaitsForTheTVScreen(t *testing.T) {
+ t0 := time.Date(2026, 10, 15, 20, 0, 0, 0, time.UTC)
+ v := netView{now: t0, offlineSince: t0.Add(-time.Hour), lastTV: t0.Add(-10 * time.Second)}
+ if got, _ := decideNet(v); got != netNothing {
+ t.Errorf("hotspot started under the TV's Wi-Fi screen: %q", got)
+ }
+ v.lastTV = t0.Add(-tvQuiet)
+ if got, _ := decideNet(v); got != netStartAP {
+ t.Errorf("hotspot held after the TV screen went quiet: %q", got)
+ }
+}
+
+func TestStatusCountsDownToTheHotspot(t *testing.T) {
+ d, _, _ := newHotspotDaemon(t, "")
+ t0 := time.Now()
+ d.netTick(t0)
+ st := d.wifiStatusAt(t0.Add(15 * time.Second))
+ if st["hotspotIn"] != 45 || st["device"] != true {
+ t.Fatalf("status = %v, want hotspotIn 45 with a device", st)
+ }
+ if st := d.wifiStatusAt(t0.Add(2 * bootGrace)); st["hotspotIn"] != 0 {
+ t.Errorf("hotspotIn past the grace = %v, want 0", st["hotspotIn"])
+ }
+ upHotspot(t, d)
+ if _, ok := d.wifiStatus()["hotspotIn"]; ok {
+ t.Error("a countdown with the hotspot already up")
+ }
+
+ // No Wi-Fi device: no countdown, and the reason.
+ d2, _, _ := newHotspotDaemon(t, "")
+ d2.net.device, d2.net.deviceErr = "", "no Wi-Fi device"
+ if st := d2.wifiStatus(); st["device"] != false || st["unavailable"] != "no Wi-Fi device" || st["hotspotIn"] != nil {
+ t.Errorf("no-device status = %v", st)
+ }
+}
+
+func TestStartHotspotNowFromTheTV(t *testing.T) {
+ d, _, f := newHotspotDaemon(t, "")
+ d.netTick(time.Now()) // offline, well inside the grace
+ rec := post(t, d.bridgeMux(), "/wifi/hotspot", "", "Origin", "http://127.0.0.1:8099")
+ if rec.Code != http.StatusAccepted {
+ t.Fatalf("start now: %d %s", rec.Code, rec.Body)
+ }
+ waitFor(t, d.hotspotUp)
+ if _, up := f.snapshot(); !up {
+ t.Fatal("AP not up")
+ }
+
+ // Already online: nothing to set up.
+ d2, _, f2 := newHotspotDaemon(t, "")
+ f2.online = true
+ if rec := post(t, d2.bridgeMux(), "/wifi/hotspot", ""); rec.Code != http.StatusConflict ||
+ !strings.Contains(rec.Body.String(), "already connected") {
+ t.Errorf("online box: %d %s", rec.Code, rec.Body)
+ }
+}
+
+func TestStartFailureReachesTheTV(t *testing.T) {
+ d, _, f := newHotspotDaemon(t, "")
+ f.mu.Lock()
+ f.startErr = errors.New("No suitable device found for this connection")
+ f.mu.Unlock()
+ upHotspot(t, d)
+ if d.hotspotUp() {
+ t.Fatal("hotspot up despite the failure")
+ }
+ if st := d.wifiStatus(); !strings.Contains(st["startError"].(string), "No suitable device") {
+ t.Errorf("status = %v, want the start error", st)
+ }
+}
+
+func TestWiFiControlsOnlyForTheKiosk(t *testing.T) {
+ d, _, _ := newHotspotDaemon(t, "server_url=https://s.example\n")
+ for _, origin := range []string{"https://s.example", "null", "http://evil.example"} {
+ if rec := post(t, d.bridgeMux(), "/wifi/scan", "", "Origin", origin); rec.Code != http.StatusForbidden {
+ t.Errorf("origin %q: %d, want 403", origin, rec.Code)
+ }
+ }
+ req := httptest.NewRequest(http.MethodPost, "/wifi/scan", nil)
+ req.Host = "rebound.example:8099"
+ rec := httptest.NewRecorder()
+ d.bridgeMux().ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden {
+ t.Errorf("rebound host: %d, want 403", rec.Code)
+ }
+}
+
+func TestTVScanTakesTheRadioFromTheHotspot(t *testing.T) {
+ d, _, f := newHotspotDaemon(t, "")
+ upHotspot(t, d)
+ rec := post(t, d.bridgeMux(), "/wifi/scan", "", "Origin", "http://127.0.0.1:8099")
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"ssid":"Home"`) {
+ t.Fatalf("scan: %d %s", rec.Code, rec.Body)
+ }
+ if _, up := f.snapshot(); up || d.hotspotUp() {
+ t.Fatal("hotspot still holds the radio after a TV scan")
+ }
+ // And the watch loop leaves it down while the screen is in use.
+ d.netTick(time.Now().Add(time.Hour))
+ if d.hotspotUp() {
+ t.Fatal("hotspot came back under the TV's Wi-Fi screen")
+ }
+ if body := get(t, d.bridgeMux(), "/wifi").Body.String(); !strings.Contains(body, "/wifi/scan") {
+ t.Error("TV Wi-Fi screen doesn't scan")
+ }
+}
+
+func TestTVJoin(t *testing.T) {
+ d, rec, f := newHotspotDaemon(t, "")
+ f.onConnect = func() {
+ f.mu.Lock()
+ f.online = true
+ f.mu.Unlock()
+ }
+ post(t, d.bridgeMux(), "/wifi/scan", "")
+ form := "ssid=Home&password=short"
+ if r := post(t, d.bridgeMux(), "/wifi/join", form, "Content-Type", "application/x-www-form-urlencoded"); r.Code != http.StatusBadRequest ||
+ !strings.Contains(r.Body.String(), "8 to 63") {
+ t.Fatalf("short password: %d %s", r.Code, r.Body)
+ }
+ form = "ssid=Home&password=hunter2222"
+ if r := post(t, d.bridgeMux(), "/wifi/join", form, "Content-Type", "application/x-www-form-urlencoded"); r.Code != http.StatusAccepted {
+ t.Fatalf("join: %d %s", r.Code, r.Body)
+ }
+ waitFor(t, func() bool { return !d.wifiSetup() })
+ if calls, _ := f.snapshot(); !strings.Contains(strings.Join(calls, ";"), "connect Home hunter2222") {
+ t.Errorf("calls = %q", calls)
+ }
+ waitFor(t, func() bool { return strings.Contains(strings.Join(rec.calls(), ";"), dataPersist+" save") })
+}
+
+func TestTVJoinFailureStaysOnTheTV(t *testing.T) {
+ d, _, f := newHotspotDaemon(t, "")
+ f.connectErr = errors.New("couldn't join Home — is the password right?")
+ post(t, d.bridgeMux(), "/wifi/scan", "")
+ post(t, d.bridgeMux(), "/wifi/join", "ssid=Home&password=wrongpass", "Content-Type", "application/x-www-form-urlencoded")
+ waitFor(t, func() bool {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ return d.net.joining == ""
+ })
+ if _, up := f.snapshot(); up || d.hotspotUp() {
+ t.Fatal("a failed TV join brought the hotspot up under the screen")
+ }
+ if st := d.wifiStatus(); !strings.Contains(st["error"].(string), "is the password right?") {
+ t.Errorf("status = %v, want the join error", st)
+ }
+}
diff --git a/boxd/internal/daemon/pages.go b/boxd/internal/daemon/pages.go
index 034b086..f37c287 100644
--- a/boxd/internal/daemon/pages.go
+++ b/boxd/internal/daemon/pages.go
@@ -40,12 +40,50 @@ var pageStyle = `
.steps { text-align: left; display: inline-block; }
`
+// controlStyle is for the screens with buttons (the Wi-Fi controls): a
+// pointer when there is a mouse, and a focus ring big enough to follow
+// from the couch when the remote's arrows or a keyboard move it.
+var controlStyle = `
+ body { cursor: auto; }
+ .actions { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; margin-top: 1.5rem; }
+ button, .button {
+ font: inherit; font-size: 1.3rem; font-weight: 600;
+ padding: 0.9rem 1.6rem; border-radius: 0.8rem; border: 0;
+ color: oklch(0.98 0.01 295); background: oklch(0.45 0.112 295);
+ text-decoration: none; cursor: pointer;
+ }
+ button.secondary, .button.secondary { background: #2c2c34; color: #f4f4f5; }
+ button:disabled { opacity: 0.5; cursor: default; }
+ button:focus-visible, .button:focus-visible, input:focus-visible {
+ outline: 4px solid oklch(0.75 0.16 295); outline-offset: 3px;
+ }
+ .count { font-variant-numeric: tabular-nums; }
+`
+
+// arrowFocusJS lets a TV remote (whose arrows arrive as arrow keys, see
+// the cec package) walk the buttons: Up/Left and Down/Right move the
+// focus, Enter clicks. Typing fields keep their own Left/Right.
+const arrowFocusJS = `
+ document.addEventListener('keydown', (e) => {
+ const keys = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 };
+ if (!(e.key in keys)) return;
+ const typing = document.activeElement && document.activeElement.tagName === 'INPUT';
+ if (typing && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) return;
+ const all = [...document.querySelectorAll('button, a.button, input')]
+ .filter((el) => !el.disabled && el.offsetParent !== null);
+ if (!all.length) return;
+ const i = all.indexOf(document.activeElement);
+ const next = all[(i < 0 ? 0 : i + keys[e.key] + all.length) % all.length];
+ next.focus();
+ e.preventDefault();
+ });
+`
+
type setupScreenData struct {
- QR string
- URL string
- IPs []string
- Network string
- Notice string // after a hotspot join that couldn't save the server URL
+ QR string
+ URL string
+ IPs []string
+ Notice string // after a hotspot join that couldn't save the server URL
}
func setupScreenHTML(d setupScreenData) string {
@@ -60,7 +98,7 @@ func setupScreenHTML(d setupScreenData) string {
Syncphony Box — set up
-
+
@@ -71,29 +109,89 @@ func setupScreenHTML(d setupScreenData) string {
{{if .IPs}}or try {{range .IPs}}{{.}} {{end}}
{{end}}
There you can set which Syncphony server this box shows.
{{if .Notice}}{{.Notice}}
{{end}}
- {{if .NoNetwork}}No network yet — without Ethernet or Wi-Fi, this box
- starts its own setup Wi-Fi in a minute.
{{end}}
+