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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,11 @@ iOS 26 is the minimum. iPhone only.

- Pair: camera QR, a link from outside shown before it is trusted (also a
pairing QR scanned with the iOS camera, as a universal link), and the live
demo against a simulated box. In the simulator, which has no camera, a
picture of the QR from Photos takes the same path.
demo against a simulated box. A QR picture from Photos works on an iPhone
too. Enter pairing code accepts a full pairing link, or a short code and
the box's local address. The short-code path needs
[Core #1721](https://github.com/srcfl/ftw/pull/1721) and the home Wi-Fi;
it asks for the house password when the box requires it.
- Now: one sentence, the energy flow, the price card with the cheapest two
hours, today's totals and the fuse. The freshness band above it keeps
carrier and source state apart.
Expand Down
7 changes: 7 additions & 0 deletions appleApp/FTW-Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSLocalNetworkUsageDescription</key>
<string>FTW connects to your box on your home network to exchange the pairing code.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>FTWAppGroup</key>
<string>$(FTW_APP_GROUP)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
Expand Down
1 change: 1 addition & 0 deletions appleApp/FTW/Intents/en.lproj/InfoPlist.strings
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"NSLocalNetworkUsageDescription" = "FTW connects to your box on your home network to exchange the pairing code.";
1 change: 1 addition & 0 deletions appleApp/FTW/Intents/sv.lproj/InfoPlist.strings
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"NSLocalNetworkUsageDescription" = "FTW ansluter till din box på hemnätet för att byta in parkoden.";
59 changes: 47 additions & 12 deletions appleApp/FTW/UI/PairView.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import FTWKit
import SwiftUI
#if targetEnvironment(simulator)
import PhotosUI
#endif

/// Pairing, the first thing anyone sees. Scan, then Face ID. Everything that
/// can fail does so before the passkey prompt.
Expand All @@ -13,9 +11,7 @@ import PhotosUI
struct PairView: View {
let app: AppModel
@State private var model: PairModel
#if targetEnvironment(simulator)
@State private var picture: PhotosPickerItem?
#endif

init(app: AppModel) {
self.app = app
Expand Down Expand Up @@ -55,6 +51,7 @@ struct PairView: View {
private var title: String {
switch model.stage {
case .scanning: return L("Scan the pairing QR")
case .enteringCode: return L("Enter pairing code")
case .pairing: return L("Securing this phone")
case .demoing: return L("Starting the demo")
case .intro:
Expand All @@ -74,6 +71,41 @@ struct PairView: View {
.overlay(RoundedRectangle(cornerRadius: 24).strokeBorder(Theme.accentFill, lineWidth: 3).padding(60))
Hint(L("In your box's local dashboard, open Settings → FTW app. Hold the pairing QR inside the frame."))

case .enteringCode:
Text(L("Stay on your home Wi-Fi.")).font(.callout)
Text(L("Box address")).font(.caption).foregroundStyle(Theme.ink2)
TextField(L("Box address"), text: $model.boxAddress)
.textFieldStyle(.roundedBorder)
.textContentType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.accessibilityIdentifier("pair.address")
Text(L("Pairing code")).font(.caption).foregroundStyle(Theme.ink2)
TextField(L("Pairing code"), text: $model.code)
.textFieldStyle(.roundedBorder)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
.accessibilityIdentifier("pair.code")
PasteButton(payloadType: String.self) { values in
if let value = values.first { model.code = value }
}
if model.needsPassword {
SecureField(L("House password"), text: $model.password)
.textContentType(.password)
.accessibilityIdentifier("pair.password")
}
if let message = model.message { Problem(message) }
if model.codeBusy { ProgressView(L("Connecting to your box")) }
DisclosureGroup(L("How?")) {
VStack(alignment: .leading, spacing: 8) {
Text(L("On this iPhone, open your box in Safari. In Settings → FTW app, choose Use a pairing code. Return here and enter it while on your home Wi-Fi."))
Hint(L("Copying the code in Safari includes the box address. You can also paste a full pairing link here."))
}
.font(.callout)
.padding(.top, 6)
}

case .pairing:
Text(L("Confirm with Face ID or Touch ID if your phone asks."))
ProgressView()
Expand All @@ -95,6 +127,10 @@ struct PairView: View {
/// quiet way out.
@ViewBuilder private var actions: some View {
switch model.stage {
case .enteringCode:
prominent(L("Connect")) { Task { await model.connectCode() } }
.disabled(model.codeBusy || model.code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
Button(L("Cancel")) { model.cancel() }.buttonStyle(.quiet)
case .scanning, .pairing:
Button(L("Cancel")) { model.cancel() }
.buttonStyle(.glass)
Expand All @@ -110,8 +146,12 @@ struct PairView: View {
} else if model.canOpen, let known = model.known {
prominent(NodeWords.format("Open %@", known.label)) { model.openKnown() }
Button(L("Scan a new pairing QR")) { model.stage = .scanning }.buttonStyle(.quiet)
Button(L("Enter pairing code")) { model.enterCode() }.buttonStyle(.quiet)
} else {
prominent(L("Scan the pairing QR")) { model.stage = .scanning }
Button(L("Enter pairing code")) { model.enterCode() }
.buttonStyle(.quiet)
.accessibilityIdentifier("pair.enterCode")
if canDismiss {
Button(L("Not now")) { model.dismiss() }.buttonStyle(.quiet)
}
Expand Down Expand Up @@ -189,7 +229,7 @@ struct PairView: View {
private var setup: some View {
Card {
Text(L("Connect your own box")).font(.title3.weight(.semibold))
Text(L("At home, open your box's FTW dashboard, then Settings → FTW app → Show pairing code."))
Text(L("Have only this iPhone? Open the box in Safari, then Settings → FTW app. Open the pairing link in FTW or use a pairing code."))
.font(.callout)
DisclosureGroup(L("How?")) {
VStack(alignment: .leading, spacing: 6) {
Expand All @@ -198,25 +238,21 @@ struct PairView: View {
Text(L("3. Choose Show pairing code, then scan the QR here."))
Hint(L("The QR code is inside FTW Settings. It is not printed on the Raspberry Pi or its case."))
Hint(L("Next, your phone asks for Face ID or Touch ID to protect your FTW key. There is no FTW account or password. If this phone loses its key, a new Settings QR is the way back."))
Hint(L("Can't see Show pairing code? Turn on Let the FTW app connect to this box, save, and restart the box first."))
Hint(L("Can't see Show pairing code? Turn on Let the FTW app connect to this box, then choose Save and restart FTW in the dashboard."))
}
.font(.callout)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 6)
}
.font(.footnote.weight(.medium))
#if targetEnvironment(simulator)
// The simulator has no camera. A screenshot of the QR in Photos
// takes the same path as a scan.
PhotosPicker(L("Choose a picture of the QR"), selection: $picture, matching: .images)
.buttonStyle(.quiet)
.accessibilityIdentifier("pair.picture")
.onChange(of: picture) { _, item in read(item) }
#endif
}
}
}

#if targetEnvironment(simulator)
extension PairView {
private func read(_ item: PhotosPickerItem?) {
guard let item else { return }
Expand All @@ -231,7 +267,6 @@ extension PairView {
}
}
}
#endif

struct Title: View {
let text: String
Expand Down
3 changes: 2 additions & 1 deletion appleApp/FTWKit/Sources/FTWKit/Identity/Enrollment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import Foundation
/// service"). Both are read.
///
/// Everything after `#` is a fragment and never sent in a request, so the
/// trust anchor reaches the phone optically and reaches no server on the way.
/// trust anchor reaches the phone from the box: a QR, a link, a paste, or a
/// local code exchange. It reaches no cloud server on the way.
public struct Enrollment: Equatable, Sendable {
/// The box's Noise static public key. The trust anchor.
public var boxStaticPublic: Bytes
Expand Down
115 changes: 115 additions & 0 deletions appleApp/FTWKit/Sources/FTWKit/Identity/LocalPairing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import Foundation
import Network

/// A short code is exchanged directly with the box on the home network.
/// Its full enrollment link then follows the same path as a camera scan.
public struct LocalPairing: Sendable {
public typealias Send = @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
private let send: Send

public init(send: @escaping Send = LocalPairing.request) { self.send = send }

public func redeem(address: String, code: String, password: String = "") async throws -> String {
let input = try Self.input(address: address, code: code)
var request = URLRequest(url: input.url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 20)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if !password.isEmpty { request.setValue("Bearer \(password)", forHTTPHeaderField: "Authorization") }
request.httpBody = try JSONEncoder().encode(Claim(kind: "claim", code: input.code))
let (data, response): (Data, HTTPURLResponse)
do { (data, response) = try await send(request) }
catch is CancellationError { throw CancellationError() }
catch { throw LocalPairingError.unreachable }
if response.statusCode == 401 { throw LocalPairingError.password }
guard response.statusCode == 200 else {
throw response.statusCode == 400 ? LocalPairingError.code : LocalPairingError.unavailable
}
guard data.count < 8_192, let reply = try? JSONDecoder().decode(Reply.self, from: data),
(try? Enrollment.parse(scanned: reply.url)) != nil else { throw LocalPairingError.unavailable }
return reply.url
}

/// Copying the code on the box includes its address as a fragment. The
/// code never becomes a URL path or query, including while it is pasted.
static func input(address: String, code: String) throws -> (url: URL, code: String) {
var host = address.trimmingCharacters(in: .whitespacesAndNewlines)
var typed = code.trimmingCharacters(in: .whitespacesAndNewlines)
if let pasted = URLComponents(string: typed), pasted.scheme != nil, let fragment = pasted.fragment {
var box = pasted
box.fragment = nil
host = box.string ?? ""
typed = fragment
}
let canonical = typed.uppercased().filter { !$0.isWhitespace && $0 != "-" }
.replacingOccurrences(of: "O", with: "0")
.replacingOccurrences(of: "I", with: "1")
.replacingOccurrences(of: "L", with: "1")
guard canonical.count == 8, canonical.allSatisfy({ "0123456789ABCDEFGHJKMNPQRSTVWXYZ".contains($0) }) else {
throw LocalPairingError.code
}
if !host.contains("://") { host = "http://" + host }
guard var target = URLComponents(string: host),
["http", "https"].contains(target.scheme?.lowercased() ?? ""),
let hostname = target.host, Self.localHost(hostname),
target.user == nil, target.password == nil, target.query == nil, target.fragment == nil,
target.path.isEmpty || target.path == "/",
target.port == nil || (1...65535).contains(target.port!) else { throw LocalPairingError.address }
target.path = "/api/app-link/pairing"
guard let url = target.url else { throw LocalPairingError.address }
return (url, canonical)
}

private static func localHost(_ host: String) -> Bool {
let name = host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "[]"))
if name == "localhost" || name.hasSuffix(".local") || name.hasSuffix(".localhost") || name.hasSuffix(".home.arpa") { return true }
if let address = IPv4Address(name) {
let b = Array(address.rawValue)
return b[0] == 10 || b[0] == 127 || (b[0] == 172 && (16...31).contains(b[1])) ||
(b[0] == 192 && b[1] == 168) || (b[0] == 169 && b[1] == 254)
}
if let address = IPv6Address(name) {
let b = Array(address.rawValue)
return address == .loopback || b[0] & 0xfe == 0xfc || (b[0] == 0xfe && b[1] & 0xc0 == 0x80)
}
return false
}

public static func request(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let configuration = URLSessionConfiguration.ephemeral
configuration.urlCache = nil
configuration.httpCookieStorage = nil
configuration.urlCredentialStorage = nil
configuration.waitsForConnectivity = true
configuration.timeoutIntervalForResource = 30
let session = URLSession(configuration: configuration, delegate: NoPairingRedirect(), delegateQueue: nil)
defer { session.invalidateAndCancel() }
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw LocalPairingError.unavailable }
return (data, http)
}

private struct Claim: Encodable { let kind: String; let code: String }
private struct Reply: Decodable { let url: String }
}

private final class NoPairingRedirect: NSObject, URLSessionTaskDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void) {
completionHandler(nil)
}
}

public enum LocalPairingError: Error, HelpfulError, Equatable {
case address, code, password, unreachable, unavailable

public var help: String {
let english = switch self {
case .address: "Enter the box address shown beside the pairing code, for example ftw.local:8080."
case .code: "That code has expired, was used or is incorrect. Make a new pairing code in Settings → FTW app."
case .password: "Enter the house password you use in the box's dashboard."
case .unreachable: "Could not reach the box. Stay on your home Wi-Fi, check its address and allow Local Network access for FTW in iPhone Settings."
case .unavailable: "Could not pair with a code. Update the box, make a new code and try again, or use a picture of its QR."
}
return NodeWords.text(english)
}
}
5 changes: 2 additions & 3 deletions appleApp/FTWKit/Sources/FTWKit/Identity/PairingImage.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import CoreImage
import Foundation

/// A pairing link read off a picture of the QR. The simulator has no camera,
/// so this is how pairing is tried there. The link then takes the same path
/// as one the camera read.
/// A pairing link read from a picture or screenshot of the QR. It takes the
/// same path as a camera scan, including on a phone with no second screen.
public enum PairingImage {
/// The first QR in the picture that `Enrollment` accepts, or nil.
public static func link(in imageData: Data) -> String? {
Expand Down
16 changes: 16 additions & 0 deletions appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,22 @@ public enum NodeWords {
"Get this phone back in": "Få in telefonen igen",
"Welcome back": "Välkommen tillbaka",
"Connect FTW": "Anslut FTW",
"Can't see Show pairing code? Turn on Let the FTW app connect to this box, then choose Save and restart FTW in the dashboard.": "Ser du inte Show pairing code? Slå på Let the FTW app connect to this box och välj sedan Save and restart FTW i webbgränssnittet.",
"Enter pairing code": "Ange parkod",
"Connect": "Anslut",
"Stay on your home Wi-Fi.": "Anslut till hemmets wifi.",
"Box address": "Boxens adress",
"Pairing code": "Parkod",
"House password": "Huslösenord",
"Connecting to your box": "Ansluter till boxen",
"On this iPhone, open your box in Safari. In Settings → FTW app, choose Use a pairing code. Return here and enter it while on your home Wi-Fi.": "Öppna boxen i Safari på denna iPhone. Välj Use a pairing code under Settings → FTW app. Gå tillbaka hit och ange koden medan du är på hemmets wifi.",
"Copying the code in Safari includes the box address. You can also paste a full pairing link here.": "När du kopierar koden i Safari följer boxens adress med. Du kan också klistra in en hel parkopplingslänk här.",
"Have only this iPhone? Open the box in Safari, then Settings → FTW app. Open the pairing link in FTW or use a pairing code.": "Har du bara denna iPhone? Öppna boxen i Safari och välj Settings → FTW app. Öppna parkopplingslänken i FTW eller använd en parkod.",
"Enter the box address shown beside the pairing code, for example ftw.local:8080.": "Ange boxens adress som står vid parkoden, till exempel ftw.local:8080.",
"That code has expired, was used or is incorrect. Make a new pairing code in Settings → FTW app.": "Koden har gått ut, har använts eller stämmer inte. Skapa en ny parkod under Settings → FTW app.",
"Enter the house password you use in the box's dashboard.": "Ange det huslösenord du använder i boxens webbgränssnitt.",
"Could not reach the box. Stay on your home Wi-Fi, check its address and allow Local Network access for FTW in iPhone Settings.": "Kunde inte nå boxen. Anslut till hemmets wifi, kontrollera adressen och tillåt Lokalt nätverk för FTW i iPhones inställningar.",
"Could not pair with a code. Update the box, make a new code and try again, or use a picture of its QR.": "Kunde inte parkoppla med kod. Uppdatera boxen, skapa en ny kod och försök igen, eller använd en bild av QR-koden.",
"In your box's local dashboard, open Settings → FTW app. Hold the pairing QR inside the frame.": "Öppna Settings → FTW app i boxens lokala dashboard. Håll QR-koden inom ramen.",
"Confirm with Face ID or Touch ID if your phone asks.": "Bekräfta med Face ID eller Touch ID om telefonen frågar.",
"Loading a simulated home.": "Laddar ett simulerat hem.",
Expand Down
Loading
Loading