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
4 changes: 4 additions & 0 deletions apps/ios/Origin89.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
AB8900000000000000000009 /* SetupKit in Frameworks */ = {isa = PBXBuildFile; productRef = AB8900000000000000000007 /* SetupKit */; };
AB890000000000000000000A /* SetupCore in Frameworks */ = {isa = PBXBuildFile; productRef = AB8900000000000000000008 /* SetupCore */; };
AB8900000000000000000015 /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB8900000000000000000014 /* AccountView.swift */; };
AB8900000000000000000031 /* SitesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB8900000000000000000030 /* SitesView.swift */; };
AB890000000000000000000C /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB890000000000000000000B /* SetupView.swift */; };
AB890000000000000000000E /* NetworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB890000000000000000000D /* NetworkView.swift */; };
AB8900000000000000000010 /* CodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB890000000000000000000F /* CodeScannerView.swift */; };
Expand All @@ -21,6 +22,7 @@

/* Begin PBXFileReference section */
AB8900000000000000000014 /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = "<group>"; };
AB8900000000000000000030 /* SitesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitesView.swift; sourceTree = "<group>"; };
AB8900000000000000000016 /* Account.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account.xcconfig; sourceTree = "<group>"; };
291B0A8141E0F76A304D6029 /* Signing.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Signing.xcconfig; sourceTree = "<group>"; };
8E94264721F66A5291BC0C5B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
Expand Down Expand Up @@ -69,6 +71,7 @@
isa = PBXGroup;
children = (
AB8900000000000000000014 /* AccountView.swift */,
AB8900000000000000000030 /* SitesView.swift */,
8E94264721F66A5291BC0C5B /* Assets.xcassets */,
AB890000000000000000000F /* CodeScannerView.swift */,
AB890000000000000000000D /* NetworkView.swift */,
Expand Down Expand Up @@ -161,6 +164,7 @@
files = (
D725D6BB1B4CC521C45F1B0E /* Origin89App.swift in Sources */,
AB8900000000000000000015 /* AccountView.swift in Sources */,
AB8900000000000000000031 /* SitesView.swift in Sources */,
AB890000000000000000000C /* SetupView.swift in Sources */,
AB890000000000000000000E /* NetworkView.swift in Sources */,
AB8900000000000000000010 /* CodeScannerView.swift in Sources */,
Expand Down
11 changes: 11 additions & 0 deletions apps/ios/Origin89/AccountView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ struct AccountView: View {
/// Nil in a build without a cloud.
let cloud: CloudClient?
let pairings: any AccountPairingStore
/// The generations of the pairings this account sees on this phone.
let generations: () throws -> [ControllerGeneration]

@State private var failure: String?
@State private var confirmingSignOut = false
Expand Down Expand Up @@ -90,6 +92,15 @@ struct AccountView: View {
} header: {
Text("Signed in")
}
if let cloud {
Section {
NavigationLink("Sites") {
SitesView(cloud: cloud, generations: generations, authenticate: authenticate)
}
} footer: {
Text("Link the controllers paired with this phone to a site in your account.")
}
}
Section {
Button("Sign out", role: .destructive) { confirmingSignOut = true }
.disabled(deleting)
Expand Down
5 changes: 4 additions & 1 deletion apps/ios/Origin89/SetupView.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Origin89UI
import SetupCore
import SetupKit
import SwiftUI

Expand Down Expand Up @@ -59,7 +60,9 @@ struct SetupView: View {
}
.tint(Color.origin89.action)
.sheet(isPresented: $accountShown) {
AccountView(account: account, cloud: cloud, pairings: pairings)
AccountView(
account: account, cloud: cloud, pairings: pairings,
generations: { try flow.keptGenerations(reader: RustGenerationReader()) })
}
.confirmationDialog(
confirming == .all ? "Forget all controllers?" : "Forget this controller?",
Expand Down
220 changes: 220 additions & 0 deletions apps/ios/Origin89/SitesView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import Origin89UI
import SetupKit
import SwiftUI

/// The signed-in user's sites, and the controllers this phone is paired with
/// that can be linked to one. A link names a controller generation for the
/// account; it grants no access to the controller, which pairing alone does.
struct SitesView: View {
let cloud: CloudClient
/// The generations of the pairings this account sees on this phone.
let generations: () throws -> [ControllerGeneration]
/// Presents AuthKit when the cloud wants a fresh sign-in.
let authenticate: (URL, String) async throws(AccountError) -> URL

@State private var sites: [Site]?
/// Nil when this phone's pairings could not be read.
@State private var paired: [ControllerGeneration]? = []
@State private var failure: CloudError?
@State private var busy = false
@State private var newSiteName = ""
@State private var creatingSite = false
@State private var linking: ControllerGeneration?

var body: some View {
Form {
if let failure {
Section { Origin89Notice(failure.message, tone: .alarm) }
}
if let sites {
controllers(sites)
siteList(sites)
} else {
Section { ProgressView() }
}
}
.navigationTitle("Sites")
.navigationBarTitleDisplayMode(.inline)
.disabled(busy)
.task { await reload() }
.refreshable { await reload() }
.alert("New site", isPresented: $creatingSite) {
TextField("Name", text: $newSiteName)
Button("Create") { Task { await createSite() } }
.disabled(DisplayName(newSiteName) == nil)
Button("Cancel", role: .cancel) {}
}
.sheet(item: $linking) { generation in
LinkControllerView(
generation: generation,
sites: (sites ?? []).filter { $0.role == .owner },
link: { name, site in await link(generation, named: name, to: site) })
}
}

@ViewBuilder private func controllers(_ sites: [Site]) -> some View {
Section {
if paired == nil {
Origin89Notice(
"This phone's pairings could not be read. Unlock the phone and try again.", tone: .alarm)
} else if paired?.isEmpty == true {
Text("No controller is paired with this phone yet.").foregroundStyle(.secondary)
}
ForEach(paired ?? [], id: \.self) { generation in
let site = sites.first { $0.links(generation) }
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(
site?.controllers.first { $0.generation == generation }?.name
?? generation.defaultName)
Text(site.map { "Linked to \($0.name)" } ?? "Not linked")
.font(.footnote).foregroundStyle(.secondary)
}
Spacer()
if site == nil {
Button("Link") { linking = generation }
.disabled(!sites.contains { $0.role == .owner })
}
}
}
} header: {
Text("Paired with this phone")
} footer: {
Text(
"Linking sends only the controller's ID and reset count to your account, never its setup code or this phone's key."
)
}
}

@ViewBuilder private func siteList(_ sites: [Site]) -> some View {
Section {
ForEach(sites) { site in
VStack(alignment: .leading, spacing: 2) {
Text(site.name)
Text(Self.summary(of: site)).font(.footnote).foregroundStyle(.secondary)
}
}
Button("New site") {
newSiteName = ""
creatingSite = true
}
} header: {
Text("Sites")
}
}

private static func summary(of site: Site) -> String {
let role = site.role == .owner ? "Owner" : "Admin"
return switch site.controllers.count {
case 0: "\(role) · no controllers"
case 1: "\(role) · 1 controller"
case let count: "\(role) · \(count) controllers"
}
}

private func reload() async {
paired = try? generations()
do {
sites = try await cloud.sites()
failure = nil
} catch {
failure = error
}
}

private func createSite() async {
guard let name = DisplayName(newSiteName) else { return }
busy = true
defer { busy = false }
do {
_ = try await cloud.createSite(named: name)
await reload()
} catch {
failure = error
}
}

/// Nil on success; otherwise the failure, which the link sheet shows.
private func link(
_ generation: ControllerGeneration, named name: DisplayName, to site: Site.ID
) async -> CloudError? {
do {
_ = try await cloud.link(generation, named: name, to: site, authenticate: authenticate)
} catch {
return error
}
await reload()
return nil
}
}

/// Choose a site the user owns and a name, then link.
private struct LinkControllerView: View {
let generation: ControllerGeneration
let sites: [Site]
let link: (DisplayName, Site.ID) async -> CloudError?

@State private var name: String
@State private var site: Site.ID?
@State private var failure: CloudError?
@State private var linking = false
@Environment(\.dismiss) private var dismiss

init(
generation: ControllerGeneration, sites: [Site],
link: @escaping (DisplayName, Site.ID) async -> CloudError?
) {
self.generation = generation
self.sites = sites
self.link = link
_name = State(initialValue: generation.defaultName)
_site = State(initialValue: sites.count == 1 ? sites.first?.id : nil)
}

var body: some View {
NavigationStack {
Form {
Section("Name") { TextField("Name", text: $name) }
Section("Site") {
Picker("Site", selection: $site) {
Text("Choose a site").tag(Site.ID?.none)
ForEach(sites) { Text($0.name).tag(Site.ID?.some($0.id)) }
}
}
if let failure {
Section { Origin89Notice(failure.message, tone: .alarm) }
}
}
.navigationTitle("Link controller")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
if linking {
ProgressView()
} else {
Button("Link") { Task { await submit() } }
.disabled(site == nil || DisplayName(name) == nil)
}
}
}
.disabled(linking)
}
}

private func submit() async {
guard let site, let name = DisplayName(name) else { return }
linking = true
defer { linking = false }
if let error = await link(name, site) {
failure = error == .account(.cancelled) ? nil : error
} else {
dismiss()
}
}
}

extension ControllerGeneration {
/// A name until the person gives one: the start of the `device_id`.
fileprivate var defaultName: String { "Controller \(deviceID.prefix(8))" }
}
10 changes: 10 additions & 0 deletions apps/ios/SetupBench/Sources/SetupBench/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ struct FileEnrolmentStore: EnrolmentStore {
func removeAll() throws {
try removeIfPresent(BenchFiles.directory.appending(path: "enrolments"))
}
func storedDeviceIDs() throws -> [String] {
let directory = BenchFiles.directory.appending(path: "enrolments")
let names: [String]
do {
names = try FileManager.default.contentsOfDirectory(atPath: directory.path)
} catch CocoaError.fileReadNoSuchFile {
return []
}
return names.filter(isDeviceID).sorted()
}
}

func removeIfPresent(_ url: URL) throws {
Expand Down
11 changes: 11 additions & 0 deletions apps/ios/SetupCore/Sources/SetupCore/RustControllerClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ public struct RustControllerClientFactory: ControllerClientFactory {
}
}

/// Reads a kept enrolment's generation with the Rust core, which owns its
/// encoding. The core clears its copy of the bytes.
public struct RustGenerationReader: GenerationReader {
public init() {}
public func generation(of enrolment: Data) -> ControllerGeneration? {
Origin89SetupCore.keptGeneration(kept: enrolment).map {
ControllerGeneration(deviceID: $0.deviceId, epoch: $0.epoch)
}
}
}

/// One controller, one Rust `SetupSession`, driven over a ``FrameTransport``.
actor RustControllerClient: ControllerClient {
/// Frames that answer nothing outstanding (P-024) before a step gives up.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ public struct AccountEnrolmentStore: EnrolmentStore {
try Self.both({ try own.removeAll() }, { try signedOut.removeAll() })
}

/// Both stores' controllers, each once.
public func storedDeviceIDs() throws -> [String] {
Set(try own.storedDeviceIDs()).union(try signedOut.storedDeviceIDs()).sorted()
}

private static func both(_ first: () throws -> Void, _ second: () throws -> Void) throws {
var failure: (any Error)?
do { try first() } catch { failure = error }
Expand Down
39 changes: 39 additions & 0 deletions apps/ios/SetupKit/Sources/SetupKit/Contract.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,45 @@ public protocol EnrolmentStore: Sendable {
func remove(deviceID: String) throws
/// Remove every kept enrolment.
func removeAll() throws
/// The `device_id` of every kept enrolment, sorted. Throws when the
/// storage cannot be read, as a locked phone's Keychain cannot, rather than
/// answering none.
func storedDeviceIDs() throws -> [String]
}

/// The ownership generation a kept enrolment belongs to: its controller and
/// the `epoch` it was issued at. A factory reset raises the `epoch`. Neither
/// is secret: both are on the controller's `Discover` answer.
public struct ControllerGeneration: Sendable, Hashable, Identifiable {
public var id: Self { self }
public let deviceID: String
public let epoch: UInt32
public init(deviceID: String, epoch: UInt32) {
self.deviceID = deviceID
self.epoch = epoch
}
}

/// Reads the generation from an encoded enrolment. Implemented by the Rust
/// core, which owns the encoding.
public protocol GenerationReader: Sendable {
/// Nil for bytes that do not decode as an enrolment.
func generation(of enrolment: Data) -> ControllerGeneration?
}

extension EnrolmentStore {
/// The generation of every kept enrolment, sorted by `device_id`. An entry
/// that cannot be read, or that names another controller than the one it is
/// kept under, is left out. Throws when the list itself cannot be read.
public func generations(reader: any GenerationReader) throws -> [ControllerGeneration] {
try storedDeviceIDs().compactMap { deviceID in
guard var kept = load(deviceID: deviceID) else { return nil }
defer { kept.resetBytes(in: kept.startIndex..<kept.endIndex) }
guard let generation = reader.generation(of: kept), generation.deviceID == deviceID
else { return nil }
return generation
}
}
}
public protocol ControllerClient: Sendable {
/// Take the enrolment `store` kept for this controller, before the first
Expand Down
Loading