From 7622380ffbd407252126420b22621a9db4c3bad7 Mon Sep 17 00:00:00 2001
From: Fredrik Ahlgren
Date: Sun, 18 Oct 2026 08:16:59 +0200
Subject: [PATCH] fix(pairing): connect one iPhone with a local code and clear
restart
Signed-off-by: Fredrik Ahlgren
---
.changeset/iphone-pairing-code.md | 5 +
docs/pairing.md | 31 ++++
docs/protocol-cloud.md | 8 +
go/cmd/ftw/app_pairing_claim.go | 21 +++
go/cmd/ftw/app_pairing_claim_test.go | 71 +++++++++
go/internal/api/api_app_link.go | 16 +-
go/internal/api/api_pairing_claim.go | 39 +++++
go/internal/api/api_pairing_claim_test.go | 88 +++++++++++
go/internal/appenroll/boxcode.go | 15 +-
go/internal/appenroll/pairing_claim.go | 39 +++++
go/internal/appenroll/pairing_claim_test.go | 91 ++++++++++++
go/internal/appenroll/payload.go | 3 +-
web/app-link-sharing.test.mjs | 109 ++++++++++++--
web/app-link-tab.test.mjs | 2 +-
web/settings/app-pairing.css | 22 +++
web/settings/tabs/app.js | 154 +++++++++++++++++---
16 files changed, 660 insertions(+), 54 deletions(-)
create mode 100644 .changeset/iphone-pairing-code.md
create mode 100644 docs/pairing.md
create mode 100644 go/cmd/ftw/app_pairing_claim.go
create mode 100644 go/cmd/ftw/app_pairing_claim_test.go
create mode 100644 go/internal/api/api_pairing_claim.go
create mode 100644 go/internal/api/api_pairing_claim_test.go
create mode 100644 go/internal/appenroll/pairing_claim.go
create mode 100644 go/internal/appenroll/pairing_claim_test.go
create mode 100644 web/settings/app-pairing.css
diff --git a/.changeset/iphone-pairing-code.md b/.changeset/iphone-pairing-code.md
new file mode 100644
index 000000000..862fe23f3
--- /dev/null
+++ b/.changeset/iphone-pairing-code.md
@@ -0,0 +1,5 @@
+---
+"ftw": patch
+---
+
+Pair an iPhone from the same phone's browser or with a local pairing code. Show a restart action beside the app connection setting and keep restart progress in view.
diff --git a/docs/pairing.md b/docs/pairing.md
new file mode 100644
index 000000000..6518a7a50
--- /dev/null
+++ b/docs/pairing.md
@@ -0,0 +1,31 @@
+# Pair with one iPhone
+
+On the home Wi-Fi, open the box's dashboard in Safari and choose
+**Settings → FTW app**. Turn on **Let the FTW app connect to this box** if
+needed. Choose **Save and restart FTW** and wait for the page to return.
+FTW pauses energy control while it restarts.
+
+Choose one way to connect:
+
+- **Show pairing code → Open in FTW on this iPhone.** The app shows which
+ box the link names before you connect it. If Safari keeps the link in the
+ browser, copy the pairing link and paste it under **Enter pairing code**
+ in FTW.
+- **Use a pairing code.** In FTW, choose **Enter pairing code**. Enter the
+ eight characters and the box address shown beside them. **Copy pairing
+ code and address** copies both for the app's Paste button. If the box has
+ a house password, enter it when the app asks.
+- Take a screenshot of the QR and choose **Choose a picture of the QR**
+ in FTW. The app reads the code from that image.
+
+A short code works once for five minutes. Five wrong tries cancel it.
+Making a new code cancels the previous code. The QR and link keep their
+existing expiry and single-use checks.
+
+The short-code exchange runs directly against the box's local, protected
+`POST /api/app-link/pairing` with `{"kind":"claim","code":"…"}`. It keeps
+the code's role and expiry and replaces it with a full enrollment token.
+The response carries the same v3 link as the QR. No short-code hash or box
+secret goes through a cloud lookup. Remote clients, proxies and app sessions
+cannot use this exchange. The existing house-password and browser-origin
+checks still apply.
diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md
index 1993ddd5f..14da29c6e 100644
--- a/docs/protocol-cloud.md
+++ b/docs/protocol-cloud.md
@@ -750,6 +750,14 @@ each time it receives such a roster. With no id, there is no copy.
Only a QR code is listed. A spoken code has 40 bits, and its hash would let
the service find it offline; it pairs over the relay or the home network.
+A new iPhone may exchange a short code directly with the box on the home
+network through `POST /api/app-link/pairing` with `kind: "claim"`. The box
+replaces that code with a full 128-bit enrollment token, keeping its role and
+expiry, and returns the same v3 link as the QR. Only the full token's hash
+enters the roster above. The exchange keeps the pairing route's local-address,
+house-password and browser-origin checks and refuses app sessions. See
+[Pair with one iPhone](pairing.md) for the steps.
+
**`stats`**
Signed box to service only. Never forwarded to phones or agents. The box
diff --git a/go/cmd/ftw/app_pairing_claim.go b/go/cmd/ftw/app_pairing_claim.go
new file mode 100644
index 000000000..0ee02dfa4
--- /dev/null
+++ b/go/cmd/ftw/app_pairing_claim.go
@@ -0,0 +1,21 @@
+package main
+
+import (
+ "errors"
+ "time"
+
+ "github.com/srcfl/ftw/go/internal/api"
+ "github.com/srcfl/ftw/go/internal/appenroll"
+)
+
+func (a *appLinkAPI) RedeemPairingCode(typed, lanHint string) (string, time.Time, error) {
+ code, expires, err := a.enroll.RedeemSpokenCode(typed)
+ if errors.Is(err, appenroll.ErrBadPairing) {
+ return "", time.Time{}, api.ErrBadAppPairingCode
+ }
+ if err != nil {
+ return "", time.Time{}, err
+ }
+ url, err := a.enroll.EnrollmentURL(code, lanHint)
+ return url, expires, err
+}
diff --git a/go/cmd/ftw/app_pairing_claim_test.go b/go/cmd/ftw/app_pairing_claim_test.go
new file mode 100644
index 000000000..183a073f8
--- /dev/null
+++ b/go/cmd/ftw/app_pairing_claim_test.go
@@ -0,0 +1,71 @@
+package main
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/srcfl/ftw/go/internal/api"
+ "github.com/srcfl/ftw/go/internal/apiauth"
+ "github.com/srcfl/ftw/go/internal/appenroll"
+)
+
+func TestPairingCodeHTTPReturnsTheRealBoxsV3Payload(t *testing.T) {
+ id, err := appenroll.LoadOrCreate(filepath.Join(t.TempDir(), "box.key"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ id.SetHome(func() string { return "abcdefghijklmnopqrstuvwx23" })
+ code, _, err := id.MintSpokenCode(apiauth.RoleViewer)
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := httptest.NewServer(api.New(&api.Deps{AppEnroll: &appLinkAPI{enroll: id}}).Handler())
+ defer s.Close()
+ claim := func() *http.Response {
+ t.Helper()
+ body, _ := json.Marshal(map[string]string{"kind": "claim", "code": code, "role": "owner"})
+ response, err := http.Post(s.URL+"/api/app-link/pairing", "application/json", strings.NewReader(string(body)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return response
+ }
+ response := claim()
+ defer response.Body.Close()
+ if response.StatusCode != 200 || response.Header.Get("Cache-Control") != "no-store" {
+ t.Fatal("local claim failed or was cacheable")
+ }
+ var reply struct {
+ URL string `json:"url"`
+ }
+ if err := json.NewDecoder(response.Body).Decode(&reply); err != nil {
+ t.Fatal(err)
+ }
+ link, err := url.Parse(reply.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ parts := strings.Split(link.Fragment, ".")
+ if link.Scheme != "https" || link.Host != "app.ftw.energy" || link.Path != "/p" || len(parts) != 6 || parts[0] != "v3" {
+ t.Fatal("claim did not return the unchanged v3 format")
+ }
+ enc := base64.RawURLEncoding.EncodeToString
+ token, _, pending := id.PendingCode()
+ if !pending || parts[1] != enc(id.StaticKey().Public()) || parts[2] != enc(token) || parts[4] != enc(id.RendezvousSecret()) || parts[5] != enc([]byte("abcdefghijklmnopqrstuvwx23")) {
+ t.Fatal("payload did not name this box and its full enrollment token")
+ }
+ if id.AuthorisedCount() != 0 {
+ t.Fatal("claim bypassed the Noise enrollment")
+ }
+ again := claim()
+ defer again.Body.Close()
+ if again.StatusCode != 400 {
+ t.Fatal("short code could be claimed twice")
+ }
+}
diff --git a/go/internal/api/api_app_link.go b/go/internal/api/api_app_link.go
index 1f9006277..4b025e2d5 100644
--- a/go/internal/api/api_app_link.go
+++ b/go/internal/api/api_app_link.go
@@ -15,7 +15,8 @@ import (
// Pairing a phone with this box.
//
// The box mints a single-use code and shows the payload as a QR. The app scans
-// it, and the box's static key travels optically — never through Sourceful.
+// it, or receives it directly from the box by link, paste or local code
+// exchange. The box's static key never passes through Sourceful.
// That is what lets a hostile or compelled relay deny service without being
// able to impersonate a box.
//
@@ -138,8 +139,9 @@ type appLinkPairingRequest struct {
// Role is "owner" or "viewer". Validated by the enroller against the
// registry, never against a list written here.
Role string `json:"role"`
- // Kind is "qr" or "spoken".
+ // Kind is "qr", "spoken", or "claim" for a code entered on the LAN.
Kind string `json:"kind"`
+ Code string `json:"code,omitempty"`
}
// appLinkRoleRequest changes what one paired phone may do.
@@ -177,6 +179,10 @@ func (s *Server) handleAppLinkPairing(w http.ResponseWriter, r *http.Request) {
// below rather than being filled in for.
var req appLinkPairingRequest
_ = readJSON(r, &req)
+ if req.Kind == "claim" {
+ s.handlePairingClaim(w, r, req.Code)
+ return
+ }
if req.Role == apiauth.RoleWatch {
s.handleWatchPairing(w, r, req)
return
@@ -196,10 +202,8 @@ func (s *Server) handleAppLinkPairing(w http.ResponseWriter, r *http.Request) {
// needs a person in the room. A code mintable from a phone anywhere in the
// world takes that argument away.
//
- // It would also buy nothing. A typed code carries the pairing code alone —
- // not the box's static key, not the rendezvous secret — so it re-admits a
- // phone that already holds those and cannot let in a guest's phone, which
- // has never seen this box. There is no second, weaker way to mint one.
+ // A new phone exchanges this code locally for the full enrollment payload.
+ // A phone that already knows the box can also use it in a Noise handshake.
if req.Kind == appPairingKindSpoken {
if appLinkOverSession(r) {
writeAppLinkError(w, http.StatusForbidden,
diff --git a/go/internal/api/api_pairing_claim.go b/go/internal/api/api_pairing_claim.go
new file mode 100644
index 000000000..910275fc7
--- /dev/null
+++ b/go/internal/api/api_pairing_claim.go
@@ -0,0 +1,39 @@
+package api
+
+import (
+ "errors"
+ "net/http"
+ "time"
+)
+
+var ErrBadAppPairingCode = errors.New("api: pairing code expired, used or incorrect")
+
+type appPairingRedeemer interface {
+ RedeemPairingCode(code, lanHint string) (string, time.Time, error)
+}
+
+// The existing pairing route supplies the local-address, JSON, browser-origin
+// and house-password checks. A claim never runs through an app session and
+// cannot choose a role: the box keeps the role of the code it issued.
+func (s *Server) handlePairingClaim(w http.ResponseWriter, r *http.Request, code string) {
+ if appLinkOverSession(r) {
+ writeAppLinkError(w, http.StatusForbidden, "enter a pairing code while on your home network")
+ return
+ }
+ redeemer, ok := s.deps.AppEnroll.(appPairingRedeemer)
+ if !ok {
+ writeAppLinkError(w, http.StatusServiceUnavailable, "update the box to pair with a typed code")
+ return
+ }
+ url, expires, err := redeemer.RedeemPairingCode(code, s.appLinkLANHint(r))
+ if errors.Is(err, ErrBadAppPairingCode) {
+ writeAppLinkError(w, http.StatusBadRequest, "that code has expired, was used or is incorrect; make a new code in Settings → FTW app")
+ return
+ }
+ if err != nil {
+ writeAppLinkError(w, http.StatusInternalServerError, "could not read the pairing code; make a new one and try again")
+ return
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ writeJSON(w, http.StatusOK, appLinkPairing{URL: url, ExpiresAtMs: expires.UnixMilli()})
+}
diff --git a/go/internal/api/api_pairing_claim_test.go b/go/internal/api/api_pairing_claim_test.go
new file mode 100644
index 000000000..9933c6553
--- /dev/null
+++ b/go/internal/api/api_pairing_claim_test.go
@@ -0,0 +1,88 @@
+package api
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/srcfl/ftw/go/internal/apiauth"
+)
+
+type claimEnroller struct {
+ stubEnroller
+ claimed string
+}
+
+func (e *claimEnroller) RedeemPairingCode(code, hint string) (string, time.Time, error) {
+ e.claimed = code
+ if code != "K7M2-9QRT" {
+ return "", time.Time{}, ErrBadAppPairingCode
+ }
+ return "https://app.ftw.energy/p#v3.full-payload", time.Now().Add(time.Minute), nil
+}
+
+func claimRequest() *http.Request {
+ r := httptest.NewRequest("POST", "http://ftw.local:8080/api/app-link/pairing", strings.NewReader(`{"kind":"claim","code":"K7M2-9QRT","role":"owner"}`))
+ r.RemoteAddr = "192.168.1.42:1234"
+ r.Header.Set("Content-Type", "application/json")
+ return r
+}
+
+func TestLocalPairingClaimKeepsThePairingSecurityChecks(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ change func(*http.Request)
+ want int
+ }{
+ {"local", func(r *http.Request) {}, 200},
+ {"public host", func(r *http.Request) { r.Host = "outside.example" }, 403},
+ {"remote peer", func(r *http.Request) { r.RemoteAddr = "203.0.113.1:1" }, 403},
+ {"proxy", func(r *http.Request) { r.Header.Set("Forwarded", "for=203.0.113.1") }, 403},
+ {"cross site", func(r *http.Request) { r.Header.Set("Origin", "https://outside.example") }, 403},
+ {"app session", func(r *http.Request) {
+ *r = *r.WithContext(apiauth.WithCaller(r.Context(), apiauth.Caller{Kind: apiauth.KindApp, Role: apiauth.RoleOwner, Scopes: apiauth.EveryScope()}))
+ }, 403},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ enroll := &claimEnroller{}
+ s := New(&Deps{AppEnroll: enroll})
+ r := claimRequest()
+ tc.change(r)
+ w := httptest.NewRecorder()
+ Authenticate(http.HandlerFunc(s.handleAppLinkPairing), MutationPolicy{}).ServeHTTP(w, r)
+ if w.Code != tc.want {
+ t.Fatalf("status %d: %s", w.Code, w.Body.String())
+ }
+ if tc.want != 200 && enroll.claimed != "" {
+ t.Fatal("refused request spent the code")
+ }
+ if enroll.minted != 0 {
+ t.Fatal("claim minted the requested role")
+ }
+ })
+ }
+}
+
+func TestLocalPairingClaimStillNeedsTheHousePassword(t *testing.T) {
+ enroll := &claimEnroller{}
+ s := New(&Deps{AppEnroll: enroll})
+ policy := MutationPolicy{LANAuthEnabled: func() bool { return true }, VerifyLANSecret: func(secret string) bool { return secret == "house-secret" }}
+ h := Authenticate(http.HandlerFunc(s.handleAppLinkPairing), policy)
+ for _, token := range []string{"", "house-secret"} {
+ r := claimRequest()
+ if token != "" {
+ r.Header.Set("Authorization", "Bearer "+token)
+ }
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ want := 401
+ if token != "" {
+ want = 200
+ }
+ if w.Code != want {
+ t.Fatalf("status %d, want %d: %s", w.Code, want, w.Body.String())
+ }
+ }
+}
diff --git a/go/internal/appenroll/boxcode.go b/go/internal/appenroll/boxcode.go
index 026a04a28..65e89d438 100644
--- a/go/internal/appenroll/boxcode.go
+++ b/go/internal/appenroll/boxcode.go
@@ -7,17 +7,10 @@ package appenroll
// box, reading eight characters down the phone. Everything here exists to make
// those eight characters safe to say out loud.
//
-// It is redeemed exactly where the QR code's payload is redeemed — inside
-// Noise handshake message 1 — so there is no new endpoint, no new carrier and
-// no HTTP path. The app decodes the characters back to five bytes and sends
-// those. The wire does not change at all.
-//
-// What it is not, and the box's own page says so on the screen: it is not a
-// way in for a phone that has never seen this box. These characters are the
-// pairing code and nothing else. The box's static key and its rendezvous
-// secret travel only in the QR payload, so a phone with no record of this box
-// can neither find it nor be sure the box answering is the right one. This
-// re-admits a phone that already holds those.
+// A phone that knows the box may send its five decoded bytes in Noise
+// handshake message 1. A new phone uses RedeemSpokenCode through the box's
+// protected local HTTP API to obtain the same payload a QR carries. The
+// trust anchor passes directly from the box to the phone on the home network.
//
// What it proves is not that the phone is trusted. It is that somebody was
// standing at the box: the code is minted only through the LAN-only pairing
diff --git a/go/internal/appenroll/pairing_claim.go b/go/internal/appenroll/pairing_claim.go
new file mode 100644
index 000000000..4ad3b1912
--- /dev/null
+++ b/go/internal/appenroll/pairing_claim.go
@@ -0,0 +1,39 @@
+package appenroll
+
+import (
+ "crypto/subtle"
+ "fmt"
+ "time"
+)
+
+// RedeemSpokenCode exchanges a local setup code for the full enrollment
+// payload's token. It keeps the role and expiry, and replaces the short code
+// atomically. The caller must use the box's protected, local HTTP API.
+func (i *Identity) RedeemSpokenCode(typed string) ([]byte, time.Time, error) {
+ code, err := DecodeSpokenCode(typed)
+ i.mu.Lock()
+ pending := i.pairing
+ if pending == nil || pending.kind != PairingKindSpoken || !i.now().Before(pending.expiresAt) {
+ i.mu.Unlock()
+ return nil, time.Time{}, ErrBadPairing
+ }
+ if err != nil || subtle.ConstantTimeCompare(code, pending.code) != 1 {
+ pending.attempts++
+ if pending.attempts >= MaxPairingAttempts {
+ i.pairing = nil
+ }
+ i.mu.Unlock()
+ return nil, time.Time{}, ErrBadPairing
+ }
+
+ token := make([]byte, PairingCodeBytes)
+ if _, err := i.randRead(token); err != nil {
+ i.mu.Unlock()
+ return nil, time.Time{}, fmt.Errorf("appenroll: creating enrollment token: %w", err)
+ }
+ expires := pending.expiresAt
+ i.pairing = &pairingCode{code: token, expiresAt: expires, role: pending.role, kind: PairingKindQR}
+ i.mu.Unlock()
+ i.minted()
+ return append([]byte(nil), token...), expires, nil
+}
diff --git a/go/internal/appenroll/pairing_claim_test.go b/go/internal/appenroll/pairing_claim_test.go
new file mode 100644
index 000000000..7a4b4cea6
--- /dev/null
+++ b/go/internal/appenroll/pairing_claim_test.go
@@ -0,0 +1,91 @@
+package appenroll
+
+import (
+ "bytes"
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/srcfl/ftw/go/internal/apiauth"
+)
+
+func TestLocalCodeClaimKeepsRoleExpiryAndSpendsTheShortCode(t *testing.T) {
+ id, _ := newIdentity(t)
+ now := time.Unix(1_800_000_000, 0)
+ id.now = func() time.Time { return now }
+ shown, expires, err := id.MintSpokenCode(apiauth.RoleViewer)
+ if err != nil {
+ t.Fatal(err)
+ }
+ now = now.Add(time.Minute)
+ var notified int
+ id.OnPairingCode(func() { notified++ })
+ token, until, err := id.RedeemSpokenCode(shown)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(token) != PairingCodeBytes || !until.Equal(expires) || id.pairing.role != apiauth.RoleViewer {
+ t.Fatal("claim changed the role or expiry, or returned a short token")
+ }
+ pending, _, ok := id.PendingCode()
+ if !ok || !bytes.Equal(pending, token) || notified != 1 {
+ t.Fatal("Cloud was not told of the new full token")
+ }
+ if _, _, err := id.RedeemSpokenCode(shown); !errors.Is(err, ErrBadPairing) {
+ t.Fatal("short code could be used twice")
+ }
+ if _, err := id.Authorise(appKey(t), token); err != nil {
+ t.Fatalf("full token did not enroll: %v", err)
+ }
+}
+
+func TestLocalCodeClaimSharesTheGuessLimitAndExpiry(t *testing.T) {
+ id, _ := newIdentity(t)
+ now := time.Now()
+ id.now = func() time.Time { return now }
+ shown, _, err := id.MintSpokenCode(apiauth.RoleOwner)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for range MaxPairingAttempts {
+ if _, _, err := id.RedeemSpokenCode("not a code"); !errors.Is(err, ErrBadPairing) {
+ t.Fatal(err)
+ }
+ }
+ if _, _, err := id.RedeemSpokenCode(shown); !errors.Is(err, ErrBadPairing) {
+ t.Fatal("guesses did not burn code")
+ }
+ shown, until, err := id.MintSpokenCode(apiauth.RoleOwner)
+ if err != nil {
+ t.Fatal(err)
+ }
+ now = until
+ if _, _, err := id.RedeemSpokenCode(shown); !errors.Is(err, ErrBadPairing) {
+ t.Fatal("expired code claimed")
+ }
+}
+
+func TestOnlyOneLocalClaimWins(t *testing.T) {
+ id, _ := newIdentity(t)
+ shown, _, err := id.MintSpokenCode(apiauth.RoleOwner)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var wg sync.WaitGroup
+ results := make(chan error, 10)
+ for range 10 {
+ wg.Go(func() { _, _, err := id.RedeemSpokenCode(shown); results <- err })
+ }
+ wg.Wait()
+ close(results)
+ wins := 0
+ for err := range results {
+ if err == nil {
+ wins++
+ }
+ }
+ if wins != 1 {
+ t.Fatalf("%d claims succeeded, want one", wins)
+ }
+}
diff --git a/go/internal/appenroll/payload.go b/go/internal/appenroll/payload.go
index 13f95f589..d3eb52515 100644
--- a/go/internal/appenroll/payload.go
+++ b/go/internal/appenroll/payload.go
@@ -18,7 +18,8 @@ import (
//
// Everything after the '#' is a URL fragment, and a fragment is never sent in
// an HTTP request. That is the whole design: the app's trust anchor reaches it
-// optically and reaches no server on the way, so a hostile or compelled cloud
+// from the box by QR, link, paste or local code exchange, without a cloud
+// server on the way, so a hostile or compelled cloud
// can deny service but cannot present itself as this box. Nothing here may
// ever move into the path or the query.
//
diff --git a/web/app-link-sharing.test.mjs b/web/app-link-sharing.test.mjs
index 9eab9519c..7f79b8edd 100644
--- a/web/app-link-sharing.test.mjs
+++ b/web/app-link-sharing.test.mjs
@@ -30,6 +30,9 @@ function element(tag) {
addEventListener(name, fn) {
this.listeners[name] = fn;
},
+ setAttribute(name, value) { this[name] = value; },
+ focus() {},
+ select() {},
click() {
if (this.listeners.click) this.listeners.click();
},
@@ -71,11 +74,14 @@ const IDS = [
"app-link-pair",
"app-link-share",
"app-link-enabled",
+ "app-link-code",
+ "app-link-restart",
+ "app-link-restart-status",
];
// load runs app.js, renders the tab, and hands back the DOM it wired itself
// into along with every request it made.
-function load({ devices = [], pairing = null, spoken = null, refuse = null } = {}) {
+function load({ devices = [], pairing = null, spoken = null, refuse = null, saveConfig = async () => ({ restart_required: false }), confirmRestart = true, resume = false, shellRestart = false } = {}) {
const byId = new Map();
for (const id of IDS) {
byId.set(id, element(id.startsWith("app-link-devices") || id.endsWith("slot") ? "div" : "button"));
@@ -89,10 +95,20 @@ function load({ devices = [], pairing = null, spoken = null, refuse = null } = {
const calls = [];
const confirmed = [];
+ const copied = [];
+ const opened = [];
+ const session = new Map(resume ? [["ftw.pairing.resume", "1"]] : []);
+ let shellRestarts = 0;
+ if (shellRestart) {
+ const restart = element("button");
+ restart.addEventListener("click", () => shellRestarts++);
+ byId.set("restart-now", restart);
+ }
+ let reloaded = false;
const answer = (body, ok = true) => Promise.resolve({ ok, json: () => Promise.resolve(body) });
const sandbox = {
- window: { FTWSettings: { tabs: {} } },
+ window: { FTWSettings: { tabs: {}, open(tab) { opened.push(tab); } }, sessionStorage: { getItem: (key) => session.get(key), setItem: (key, value) => session.set(key, value), removeItem: (key) => session.delete(key) }, location: { origin: "http://ftw.local:8080", reload() { reloaded = true; } }, navigator: { clipboard: { async writeText(value) { copied.push(value); } } } },
setTimeout: (fn) => {
fn();
return 0;
@@ -103,7 +119,7 @@ function load({ devices = [], pairing = null, spoken = null, refuse = null } = {
// that only said yes let it go blank without a test noticing.
confirm: (question) => {
confirmed.push(question);
- return true;
+ return confirmRestart;
},
fetch: (path, opts) => {
calls.push({ path, opts });
@@ -139,8 +155,8 @@ function load({ devices = [], pairing = null, spoken = null, refuse = null } = {
vm.runInContext(source, sandbox);
const tab = sandbox.window.FTWSettings.tabs.app;
- const html = tab.render({ config: { app_link: { enabled: true } } });
- return { html, calls, confirmed, el: (id) => byId.get(id) };
+ const html = tab.render({ config: { app_link: { enabled: true } }, saveConfig });
+ return { html, calls, confirmed, copied, opened, session, shellRestarts: () => shellRestarts, reloaded: () => reloaded, el: (id) => byId.get(id) };
}
// Lets the promise chains inside the tab settle. Two turns, because a button
@@ -449,19 +465,16 @@ describe("the code on screen", () => {
);
});
- // A typed code carries the code and nothing else. The box's own key and its
- // rendezvous secret travel only in the square, so a phone that has never
- // seen this box cannot be read its way in — and the page has to say so
- // rather than offer a path that cannot work.
- it("says a code read out will not do for a phone that has never been here", () => {
+ it("explains first pairing on one iPhone, with the local code and house password", () => {
const { html } = load({ devices: [{ id: "aaaa1111", role: "owner" }] });
- assert.match(html, /read out instead of scanned/i);
- assert.match(html, /never seen this box has to scan/i);
+ assert.match(html, /Only one iPhone/);
+ assert.match(html, /home Wi-Fi/);
+ assert.match(html, /house password/);
+ assert.doesNotMatch(html, /never seen this box has to scan/i);
});
it("never prints a scannable payload as text", async () => {
- // The QR payload is a credential. The only path it should take is a
- // camera, so it must not appear anywhere a person could copy it.
+ // The credential stays out of visible text until an explicit copy.
const payload = "https://app.ftw.energy/p#v2.secret.key.material.here";
const { el } = load({
devices: [{ id: "aaaa1111", role: "owner" }],
@@ -475,3 +488,71 @@ describe("the code on screen", () => {
assert.doesNotMatch(el("app-link-slot").words(), /secret\.key\.material/);
});
});
+
+describe("pairing and restart on one iPhone", () => {
+ it("opens the full pairing link and copies it only on request", async () => {
+ const link = "https://app.ftw.energy/p#v3.box.code.hint.secret.home";
+ const { el, copied } = load({ pairing: { url: link, expires_at_ms: Date.now() + 600000 } });
+ await settle();
+ el("app-link-pair").click();
+ await settle();
+ const slot = el("app-link-slot");
+ assert.equal(slot.children.find((c) => c.tagName === "a").href, link);
+ assert.deepEqual(copied, []);
+ slot.buttons().find((b) => b.textContent === "Copy pairing link").click();
+ await settle();
+ assert.deepEqual(copied, [link]);
+ });
+
+ it("offers the short code directly and copies the box address with it", async () => {
+ const { el, copied, calls } = load({ spoken: { code: "K7M2-9QRT", expires_at_ms: Date.now() + 300000 } });
+ await settle();
+ el("app-link-code").click();
+ await settle();
+ assert.deepEqual(JSON.parse(calls.find((c) => c.path === "/api/app-link/pairing").opts.body), { role: "owner", kind: "spoken" });
+ assert.match(el("app-link-slot").words(), /http:\/\/ftw.local:8080/);
+ el("app-link-slot").buttons().find((b) => b.textContent === "Copy pairing code and address").click();
+ await settle();
+ assert.deepEqual(copied, ["http://ftw.local:8080#K7M2-9QRT"]);
+ });
+
+ it("saves before restarting and waits for the box before reloading", async () => {
+ const order = [];
+ const page = load({ saveConfig: async () => { order.push("save"); return { restart_required: false }; } });
+ page.el("app-link-restart").click();
+ await settle();
+ assert.deepEqual(order, ["save"]);
+ assert.match(page.confirmed[0], /Energy control pauses/);
+ assert.ok(page.calls.some((c) => c.path === "/api/restart" && c.opts.method === "POST"));
+ assert.ok(page.calls.some((c) => c.path.startsWith("/api/health?")));
+ assert.equal(page.reloaded(), true);
+ assert.equal(page.session.get("ftw.pairing.resume"), "1");
+ });
+
+ it("uses the shell's restart once when Save already opened its dialog", async () => {
+ const page = load({ shellRestart: true, saveConfig: async () => ({ restart_required: true }) });
+ page.el("app-link-restart").click();
+ await settle();
+ assert.equal(page.shellRestarts(), 1);
+ assert.equal(page.calls.some((c) => c.path === "/api/restart"), false);
+ });
+
+ it("returns to the app tab once after the restart reload", () => {
+ const page = load({ resume: true });
+ assert.deepEqual(page.opened, ["app"]);
+ assert.equal(page.session.has("ftw.pairing.resume"), false);
+ });
+
+ it("keeps the restart action available after a refusal, and does nothing on cancel", async () => {
+ const refused = load({ refuse: { method: "POST", error: "restart not configured" } });
+ refused.el("app-link-restart").click();
+ await settle();
+ assert.match(refused.el("app-link-restart-status").textContent, /restart not configured/);
+ assert.equal(refused.el("app-link-restart").disabled, false);
+ assert.equal(refused.reloaded(), false);
+ const canceled = load({ confirmRestart: false });
+ canceled.el("app-link-restart").click();
+ await settle();
+ assert.equal(canceled.calls.some((c) => c.path === "/api/restart"), false);
+ });
+});
diff --git a/web/app-link-tab.test.mjs b/web/app-link-tab.test.mjs
index 86085d255..5dca9e744 100644
--- a/web/app-link-tab.test.mjs
+++ b/web/app-link-tab.test.mjs
@@ -119,7 +119,7 @@ describe("the app tab", () => {
vm.runInContext(fleetSource, sandbox);
win.FTWSettings.tabs.app.afterSave();
- assert.deepEqual(asked, ["/api/fleet-ping"]);
+ assert.deepEqual(asked, ["/api/fleet-ping", "/api/app-link/status"]);
});
it("does not leave a standalone Fleet ping destination behind", () => {
diff --git a/web/settings/app-pairing.css b/web/settings/app-pairing.css
new file mode 100644
index 000000000..271dfdd6e
--- /dev/null
+++ b/web/settings/app-pairing.css
@@ -0,0 +1,22 @@
+.app-pairing button,
+.app-pairing a.btn-send {
+ min-height: 44px;
+}
+
+.app-pairing a.btn-send {
+ display: inline-flex;
+ align-items: center;
+ text-decoration: none;
+}
+
+#app-link-slot > button,
+#app-link-slot > a {
+ margin-right: 8px;
+}
+
+#app-link-slot input[readonly] {
+ display: block;
+ width: 100%;
+ box-sizing: border-box;
+ margin: 8px 0;
+}
diff --git a/web/settings/tabs/app.js b/web/settings/tabs/app.js
index 591af4e34..d077f03ea 100644
--- a/web/settings/tabs/app.js
+++ b/web/settings/tabs/app.js
@@ -9,8 +9,8 @@
// request, so none of it reaches Sourceful. That is what lets the app trust
// this box specifically rather than whatever the relay introduces it to.
//
-// The payload is never written into innerHTML and never offered as text to
-// copy. It is a credential; the only path it should take is the camera.
+// The payload is a credential. A link or explicit copy keeps it in the URL
+// fragment; it never goes into innerHTML, a query, a log or remote storage.
(function () {
var S = (window.FTWSettings = window.FTWSettings || { tabs: {} });
S.tabs = S.tabs || {};
@@ -43,8 +43,8 @@
if (!saved) return "Running. It stops at the next restart.";
return null; // The caller shows the pairing count instead.
}
- if (saved) return "Saved. Press Save, then restart, to finish turning it on.";
- return "Off. Turn it on above, save, and restart.";
+ if (saved) return "Not running yet. Choose Save and restart FTW below to turn it on.";
+ return "Off. Turn it on above, then choose Save and restart FTW.";
}
function agoText(ms) {
@@ -225,6 +225,7 @@
// mints the same code from the same endpoint.
var buttons = [
document.getElementById("app-link-pair"),
+ document.getElementById("app-link-code"),
document.getElementById("app-link-share"),
];
var setEnabled = function (on) {
@@ -237,6 +238,8 @@
}
var note = describe(saved, s.enabled);
setStatus(note === null ? pairedText(s.paired_devices) : note);
+ var restart = document.getElementById("app-link-restart");
+ if (restart) restart.className = saved !== s.enabled ? "btn-send" : "btn-ghost";
setEnabled(s.enabled);
if (s.enabled) refreshDevices();
})
@@ -297,6 +300,7 @@
var label = "Cannot scan? Read a code out instead";
var btn = document.createElement("button");
btn.type = "button";
+ btn.className = "btn-ghost";
btn.textContent = label;
btn.addEventListener("click", function () {
requestCode({ role: role, kind: "spoken" }, btn, "Making a code…", label);
@@ -310,9 +314,6 @@
slot.textContent = "";
sayWhatItLetsIn(slot, pairing.role);
- // A box code is meant to be read down a phone, so it is shown as text and
- // nothing else. The QR payload never is: it is a credential, and the only
- // path it should take is a camera.
if (pairing.code) {
var spoken = document.createElement("p");
spoken.className = "mono app-box-code";
@@ -324,10 +325,17 @@
// What to do, not what to tap. The button that takes this code lives in
// the app, in another repository, and a label quoted here would go on
// being quoted long after the app renamed it.
- how.textContent = "Read this out. On the other phone, choose to type a code " +
- "rather than scan one. " + expiryText(pairing) + " Five wrong tries and it " +
+ how.textContent = "In FTW for iPhone, choose Enter pairing code while on your home Wi-Fi. " +
+ "Enter this code and the box address shown below. " + expiryText(pairing) + " Five wrong tries and it " +
"stops working, so ask for a new one rather than guessing.";
slot.appendChild(how);
+ if (window.location && window.location.origin) {
+ var address = document.createElement("p");
+ address.textContent = "Box address: " + window.location.origin;
+ slot.appendChild(address);
+ copyButton(slot, "Copy pairing code and address", window.location.origin + "#" + pairing.code);
+ }
+ if (slot.scrollIntoView) slot.scrollIntoView({ block: "nearest" });
return;
}
@@ -335,6 +343,15 @@
// it, whenever the encoder finishes.
var picture = document.createElement("div");
slot.appendChild(picture);
+ if (/^https:\/\/app\.ftw\.energy\/p#v[23]\./.test(pairing.url)) {
+ var open = document.createElement("a");
+ open.href = pairing.url;
+ open.rel = "noreferrer";
+ open.className = "btn-send";
+ open.textContent = "Open in FTW on this iPhone";
+ slot.appendChild(open);
+ copyButton(slot, "Copy pairing link", pairing.url);
+ }
slot.appendChild(spokenFallback(pairing.role));
// Loaded on demand: this tab is opened once per phone, and every other
@@ -358,6 +375,81 @@
});
}
+ function copyButton(slot, label, text) {
+ var button = document.createElement("button");
+ button.type = "button";
+ button.className = "btn-ghost";
+ button.textContent = label;
+ button.addEventListener("click", function () {
+ var clipboard = window.navigator && window.navigator.clipboard;
+ function manualCopy() {
+ var field = document.createElement("input");
+ field.type = "text";
+ field.readOnly = true;
+ field.value = text;
+ field.setAttribute("aria-label", label);
+ slot.appendChild(field);
+ field.focus();
+ field.select();
+ button.textContent = "Select the text above and copy it";
+ }
+ if (!clipboard) return manualCopy();
+ clipboard.writeText(text).then(function () { button.textContent = "Copied"; }).catch(manualCopy);
+ });
+ slot.appendChild(button);
+ }
+
+ async function restartAppLink(ctx, button) {
+ if (!confirm("Save settings and restart FTW? Energy control pauses until FTW is back.")) return;
+ var status = document.getElementById("app-link-restart-status");
+ button.disabled = true;
+ status.textContent = "Saving settings…";
+ resumePairing(true);
+ try {
+ var result = await ctx.saveConfig();
+ // Keep the shell's existing dialog and progress when saving opens it.
+ var restart = document.getElementById("restart-now");
+ if (result && result.restart_required && restart) {
+ restart.click();
+ button.disabled = false;
+ status.textContent = "Follow the restart progress above.";
+ return;
+ }
+ var response = await fetch("/api/restart", { method: "POST" });
+ if (!response.ok) {
+ var failure = await response.json();
+ throw new Error(failure.error || "Restart was refused.");
+ }
+ status.textContent = "Restarting FTW. Waiting for the box to return…";
+ var until = Date.now() + 90000;
+ function waitForBox() {
+ fetch("/api/health?_=" + Date.now(), { cache: "no-store" })
+ .then(function (r) {
+ if (!r.ok) throw new Error("not ready");
+ status.textContent = "FTW is back. Opening pairing…";
+ window.location.reload();
+ })
+ .catch(function () {
+ if (Date.now() < until) return setTimeout(waitForBox, 1000);
+ status.textContent = "FTW has not returned yet. Check the box, then reload this page.";
+ button.disabled = false;
+ });
+ }
+ setTimeout(waitForBox, 1500);
+ } catch (error) {
+ resumePairing(false);
+ status.textContent = "Could not restart FTW: " + error.message;
+ button.disabled = false;
+ }
+ }
+
+ function resumePairing(on) {
+ try {
+ if (on) window.sessionStorage.setItem("ftw.pairing.resume", "1");
+ else window.sessionStorage.removeItem("ftw.pairing.resume");
+ } catch (_) { /* Pairing still works if the browser disallows storage. */ }
+ }
+
// One code at a time, whatever kind it is: asking for a guest pass cancels
// a pairing code still on screen, and the reverse. Every button here goes
// through this, so the rule is not something a caller has to remember.
@@ -401,6 +493,9 @@
button.disabled = false;
button.textContent = doneText;
refreshStatus(pairingCtx);
+ // Restoring the label can wrap the action row on an iPhone. Scroll
+ // after that layout change so the code and Copy button stay in view.
+ if (slot.scrollIntoView) slot.scrollIntoView({ block: "nearest" });
});
}
@@ -415,6 +510,7 @@
// own restart boundary; fleet sharing takes effect as soon as it saves.
var panel = S.fleetPing;
if (panel && panel.afterSave) panel.afterSave();
+ refreshStatus(pairingCtx);
},
render: function (ctx) {
@@ -431,6 +527,13 @@
});
}
+ var codeButton = document.getElementById("app-link-code");
+ if (codeButton) codeButton.addEventListener("click", function () {
+ requestCode({ role: "owner", kind: "spoken" }, codeButton, "Making a code…", "Use a pairing code");
+ });
+ var restartButton = document.getElementById("app-link-restart");
+ if (restartButton) restartButton.addEventListener("click", function () { restartAppLink(ctx, restartButton); });
+
var shareButton = document.getElementById("app-link-share");
if (shareButton) {
shareButton.addEventListener("click", function () {
@@ -452,8 +555,9 @@
}, 0);
return (
+ '' +
'' +
- "
" +
- '
A code can be read out instead of scanned. The offer sits ' +
- "under whichever code is on screen and lets in exactly the same access. It " +
- "is for a phone that has been here before and is being set up again — a " +
- "phone that has never seen this box has to scan, because only the square " +
- "carries what it needs to find this box and be sure it is this one. Only " +
- "one code is live at a time, so asking for any of them stops the last " +
- "one.
" +
+ '
Only one iPhone? Open the pairing link on this phone, ' +
+ "or choose Use a pairing code and enter it in the iPhone app while on your home Wi-Fi. " +
+ "If you set a house password, the app asks for it too. You can also choose a screenshot " +
+ "of the QR in the iPhone app. Only one code works at a time; making a new code cancels the last one.
" +
"" +
fleetPanel(ctx)
);
},
};
+
+ // A restart reloads the whole page. Return to this step rather than leave
+ // the person on the dashboard looking for their unfinished pairing.
+ try {
+ if (window.sessionStorage.getItem("ftw.pairing.resume") === "1" && S.open) {
+ resumePairing(false);
+ setTimeout(function () { S.open("app"); }, 0);
+ }
+ } catch (_) { /* Settings can still be opened by hand. */ }
})();