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 ( + '' + '
' + - "