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 appleApp/FTW/Access/AccessView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ struct AccessView: View {
Text(AccessWords.text("Assistants such as Claude or ChatGPT reach your home through FTW Cloud, with what you let them. Your box checks every call."))
}

if access.invitationsAvailable {
Section { NavigationLink { InvitationsView(app:app,access:access) } label: { Label(AccessWords.text("Shared access"),systemImage:"person.2.badge.key") } }
}

Section(AccessWords.text("Sourceful support")) {
ForEach(access.support) { row in
NavigationLink { GrantView(app: app, access: access, id: row.id) } label: { GrantLine(access: access, row: row) }
Expand Down
249 changes: 249 additions & 0 deletions appleApp/FTW/Access/InvitationsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import FTWKit
import SwiftUI

struct InvitationsView: View {
let app: AppModel
let access: AccessModel

var body: some View {
List {
Section {
NavigationLink {
NewInvitationView(app: app, access: access)
} label: {
Label(AccessWords.text("Create an invitation"), systemImage: "plus")
}
} footer: {
Text(
AccessWords.text(
"Share a code with someone who will use their own assistant. The first person who uses it gets a separate connection you can remove."
))
}
ForEach(access.invitations) { row in
Section {
Text(AccessWords.clean(row.label.isEmpty ? AccessWords.text("Guest") : row.label)).font(
.headline)
Text(AccessWords.summary(row.scopes)).font(.footnote).foregroundStyle(Theme.ink2)
Text(
AccessWords.text(
row.state == "pending"
? (row.clockUnavailable ? "Waiting for the box's clock" : "Waiting to be used")
: row.state == "claimed" ? "Access is given" : "Invitation ended"))
LabeledContent(AccessWords.text("Use before"), value: when(row.claimUntilMs))
LabeledContent(AccessWords.text("Access ends"), value: when(row.expiresAtMs))
if row.state == "pending" {
Text(
AccessWords.text(
"The code cannot be shown again. Cancel this invitation and create a new one if needed."
)
).font(.footnote).foregroundStyle(Theme.ink2)
Button(AccessWords.text("Cancel invitation"), role: .destructive) {
Task { _ = await access.cancelInvitation(row) }
}
} else if row.state == "claimed", access.row(row.agentID) != nil {
NavigationLink {
GrantView(app: app, access: access, id: row.agentID)
} label: {
Label(AccessWords.text("View or remove access"), systemImage: "person.badge.key")
}
}
}
}
if let help = access.help { Section { Text(help).foregroundStyle(Theme.ink2) } }
}
.navigationTitle(AccessWords.text("Shared access"))
.task {
await access.refreshInvitations()
await access.refresh()
}
.refreshable {
await access.refreshInvitations()
await access.refresh()
}
}
}

private func when(_ ms: Int64) -> String {
Date(timeIntervalSince1970: Double(ms) / 1000).formatted(date: .abbreviated, time: .shortened)
}

struct NewInvitationView: View {
let app: AppModel
let access: AccessModel
@State private var draft = InvitationDraft()
@State private var label = ""
@State private var scopes = Set(
(Contract.agentRoleScopes[AccessWire.roleAgent] ?? []).filter {
!AccessWire.controlScopes.contains($0) && $0 != AccessWire.scopeSetup
})
@State private var setup = false
@State private var control = false
@State private var lengthMs: Int64 = 7 * 86_400_000
@State private var power: Double = 11_000
@State private var hold: Double = 900
@Environment(\.scenePhase) private var scenePhase

var body: some View {
Form {
if let receipt = draft.receipt {
Section(
AccessWords.text(receipt.state == "pending" ? "Invitation code" : "Invitation ended")
) {
if let code = access.invitationCode(draft) {
Text(code).font(.system(.largeTitle, design: .monospaced)).textSelection(.enabled)
ShareLink(item: code) {
Label(AccessWords.text("Share code"), systemImage: "square.and.arrow.up")
}
Text(
AccessWords.text(
"They connect FTW in their assistant, choose ‘I have an invitation code’ on the FTW connection page and paste it. The box must be online when they use it. Anyone with the unused code can claim these rights."
))
} else if receipt.state == "pending", draft.alias == nil {
Text(
AccessWords.text("Waiting for the box to publish the code. Keep this page open and retry the same invitation."))
Button(AccessWords.text("Get the six-character code")) { Task { await access.publishInvitation(draft) } }
.disabled(access.working != nil)
} else {
Text(
AccessWords.text(
receipt.state == "claimed"
? "Access is already given. Open Shared access to remove its connection."
: receipt.state == "pending"
? "This code has ended. Check status before creating a new invitation."
: "This invitation has ended."))
}
if receipt.state == "pending" {
Button(AccessWords.text("Check status and code")) { Task { await checkStatus() } }
.disabled(access.working != nil)
if let help = access.help { Text(help).font(.footnote).foregroundStyle(Theme.ink2) }
}
LabeledContent(AccessWords.text("Use before"), value: when(draft.alias?.expiresAtMs ?? receipt.claimUntilMs))
LabeledContent(AccessWords.text("Access ends"), value: when(receipt.expiresAtMs))
}
} else {
Section {
TextField(AccessWords.text("Who is this for?"), text: $label)
ForEach(
Approval.sorted(Contract.agentRoleScopes[AccessWire.roleAgent] ?? []).filter {
!AccessWire.controlScopes.contains($0) && $0 != AccessWire.scopeSetup
}, id: \.self
) { scope in
Toggle(
AccessWords.scope(scope),
isOn: Binding(
get: { scopes.contains(scope) },
set: { if $0 { scopes.insert(scope) } else { scopes.remove(scope) } }))
}
Toggle(AccessWords.text("Allow setup"), isOn: $setup)
if setup {
Text(
AccessWords.text(
"Setup can save equipment and electrical settings. Review and confirm the exact draft before saving and confirm any restart separately."
)
).font(.footnote)
}
Toggle(AccessWords.text("Also let it control"), isOn: $control)
if control {
ForEach(Approval.sorted(Contract.agentOps.keys), id: \.self) { op in
Text(AccessWords.op(op)).font(.footnote)
}
LabeledContent(AccessWords.text("Power limit (W)")) {
TextField("11000", value: $power, format: .number).keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
}
LabeledContent(AccessWords.text("Hold limit (seconds)")) {
TextField("900", value: $hold, format: .number).keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
}
}
Picker(AccessWords.text("Access lasts"), selection: $lengthMs) {
ForEach(access.invitationLengths, id: \.self) { length in
Text(AccessWords.length(length)).tag(length)
}
}
Text(
AccessWords.text(
"The six-character code works once and ends within ten minutes. Access lasts as long as you choose above."))
} footer: {
Text(
AccessWords.text(
"The label does not check the recipient's identity. Technical backups can contain saved credentials. The code appears only while this page stays open."
))
}
.disabled(draft.request != nil)
if draft.request != nil {
Section {
Text(
AccessWords.text(
"Creation is not confirmed. Retry sends the exact same invitation. The Shared access list can show whether it already exists."
))
}
}
Section {
Button {
Task { await create() }
} label: {
if access.working == "invitation" {
ProgressView()
} else {
Text(
draft.request == nil
? AccessWords.format("Create code with %@", AccessWords.biometry)
: AccessWords.text("Retry the same invitation"))
}
}.disabled(
(app.cloudHome == nil && !app.isDemo) || access.working != nil
|| scopes.isEmpty && !setup && !control
|| control
&& (!power.isFinite || !hold.isFinite || power < 1 || power > 100_000 || hold < 60
|| hold > 1800)
)
if app.cloudHome == nil && !app.isDemo {
Text(
AccessWords.text("Wait for the box to connect to FTW Cloud before sharing access."))
}
if let help = access.help { Text(help).foregroundStyle(Theme.ink2) }
}
}
}
.navigationTitle(AccessWords.text("New invitation"))
.task {
await access.refresh()
keepLengthValid()
await checkStatus()
}
.onChange(of: access.invitationLengths) { _, _ in keepLengthValid() }
.onChange(of: scenePhase) { _, phase in if phase == .active { Task { await checkStatus() } } }
}

private func keepLengthValid() {
guard draft.request == nil, !access.invitationLengths.contains(lengthMs) else { return }
lengthMs = access.invitationLengths.last(where: { $0 <= 7 * 86_400_000 })
?? access.invitationLengths.first ?? 7 * 86_400_000
}

private func checkStatus() async {
guard draft.receipt != nil else { return }
await access.refreshInvitations()
if let row = access.invitations.first(where: { $0.id == draft.id }) { draft.receipt = row }
if draft.receipt?.state == "pending" { await access.publishInvitation(draft) }
}

private func create() async {
guard let home = app.cloudHome ?? (app.isDemo ? "abcdefghijklmnopqrstuvwx23" : nil) else {
return
}
var allowed = scopes
if setup { allowed.insert(AccessWire.scopeSetup) }
if control { allowed.formUnion(AccessWire.controlScopes) }
let ops = Approval.sorted(
Contract.agentOps.keys.filter { allowed.contains(Contract.agentOps[$0] ?? "") })
let caps = Caps(holdS: control ? UInt64(hold) : 0, maxW: control ? UInt64(power) : 0, perMin: 6)
let grant = Grant(scopes: Array(allowed), ops: ops, caps: caps, lengthMs: lengthMs)
var name = label.trimmingCharacters(in: .whitespacesAndNewlines)
while name.utf8.count > 128 { name.removeLast() }
_ = await access.createInvitation(
draft, home: home, label: name, grant: grant, claimSeconds: 600)
keepLengthValid()
}
}
37 changes: 37 additions & 0 deletions appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ public final class SimulatedAccess {
/// 30 days for an assistant.
public var longestMs: [String: Int64]? = Dictionary(uniqueKeysWithValues:
[Contract.roleAgent, Contract.roleSupport, Contract.roleRelease].map { ($0, AccessWire.longestMs($0)) })
private var invitations: [String:CBOR] = [:]
private var invitationRequests: [String:CBOR] = [:]
public var loseNextInvitationAnswer = false
private var seen = Set<Bytes>()
private var seq: UInt64 = 0
let nowMs: @MainActor () -> Double
Expand Down Expand Up @@ -99,13 +102,40 @@ public final class SimulatedAccess {
loseNextAnswer = false
reply = { _ in }
}
if env.t == InvitationWire.create && loseNextInvitationAnswer {
loseNextInvitationAnswer = false
reply = { _ in }
}
let body = env.b ?? .emptyMap
let ok = { (b: CBOR) in reply(Envelope(t: AccessWire.msgOK, id: env.id, b: b)) }
let refuse = { (code: String, args: [(String, CBOR)]) in
reply(Envelope(t: "error", id: env.id, b: .map([("code", .text(code)), ("retryable", .bool(Contract.isRetryable(code))), ("args", .map(args))])))
}
let now = Int64(nowMs())
switch env.t {
case InvitationWire.list:
ok(.map([("invitations", .array(Array(invitations.values))), ("nowMs", .int(now)), ("clockReady", .bool(true))]))
case InvitationWire.cancel:
guard let id = body["id"]?.string, let row = invitations[id], let m = InvitationCreate(invitationRequests[id] ?? .emptyMap)
else { return refuse("E_NOT_FOUND", []) }
let updated = row["state"]?.string == "pending" ? Self.invitation(m, signedFor.boxKey, state: "cancelled") : row
invitations[id] = updated; ok(.map([("invitation", updated)]))
case InvitationWire.create:
guard let m = InvitationCreate(body), let pub = keys[signedFor.deviceID], let msg = try? m.signedBytes(signedFor) else {
return refuse("E_BAD_SIGNATURE", [])
}
if let old = invitationRequests[m.id], let row = invitations[m.id] {
guard old == body else { return refuse("E_CONFLICT", []) }; ok(.map([("invitation", row)])); return
}
guard m.claimUntilMs > now, m.claimUntilMs <= now + 86_400_000, m.claimUntilMs <= m.expiresAtMs else {
return refuse("E_BAD_SIGNATURE", [("field", .text("claimUntilMs"))])
}
guard m.expiresAtMs > now, m.expiresAtMs <= now + longest(AccessWire.roleAgent) else {
return refuse("E_CAP_EXCEEDED", [("cap", .text("expiresAtMs"))])
}
if let code = check(pub, msg, m.sig, atMs: m.atMs, now: now) { return refuse(code.0, code.1) }
let row = Self.invitation(m, signedFor.boxKey, state: "pending"); invitations[m.id] = row; invitationRequests[m.id] = body
ok(.map([("invitation", row)]))
case AccessWire.msgList:
lists += 1
var list: [(String, CBOR)] = [
Expand Down Expand Up @@ -210,6 +240,10 @@ public final class SimulatedAccess {

/// The box's checks on a signature: fresh, never seen, and over these
/// bytes.
private static func invitation(_ m:InvitationCreate,_ box:Bytes,state:String)->CBOR {
.map([("id",.text(m.id)),("home",.text(m.home)),("box",.bytes(box)),("label",.text(m.label)),("state",.text(state)),("scopes",.array(m.grant.scopes.map { .text($0) })),("ops",.array(m.grant.ops.map { .text($0) })),("claimUntilMs",.int(m.claimUntilMs)),("expiresAtMs",.int(m.expiresAtMs))])
}

private func check(_ pub: Bytes, _ msg: Bytes, _ sig: Bytes, atMs: Int64, now: Int64) -> (String, [(String, CBOR)])? {
guard abs(atMs - now) <= Approval.skewMs else { return ("E_CLOCK_SKEW", [("boxMs", .int(now))]) }
guard Approval.verify(pub: pub, message: msg, sig: sig) else { return ("E_BAD_SIGNATURE", []) }
Expand Down Expand Up @@ -270,6 +304,9 @@ public final class SimulatedAccess {
/// The simulated consent page's code lookup: the demo's code finds a
/// fresh offer, any other none.
public static let lookup = OfferLookup(base: URL(string: "https://demo.invalid")!) { req in
if req.url?.path == "/oauth/invitations", req.httpMethod == "POST" {
return (try JSONSerialization.data(withJSONObject:["code":"K7M-4PX","expiresAtMs":Int64(Date().timeIntervalSince1970*1000)+600_000]),200)
}
let code = URLComponents(url: req.url!, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "code" }?.value
guard code == "K7QF2MXD" else { return (Data(), 404) }
let signed: String = await MainActor.run { () -> String in
Expand Down
4 changes: 2 additions & 2 deletions appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ public final class SimulatedBox {
/// offers. Registry names only; `ContractTests` checks.
public nonisolated static let caps = [
"status.core", "status.drivers", "history.5m", "history.1h", "history.etag", "cmd.lease",
"cmd.readback", "der.battery", "der.ev", "plan.dispatch", "price.spot", "api.passthrough", "access.agents",
"cmd.readback", "der.battery", "der.ev", "plan.dispatch", "price.spot", "api.passthrough", "access.agents", "access.invites",
]

/// Capabilities this box offers beyond `caps`, for a test that needs a
Expand Down Expand Up @@ -196,7 +196,7 @@ public final class SimulatedBox {
sendBulk(Envelope(t: "plan", id: env.id, b: sessionPlan().wire))
case "api.req": onAPI(env.id, body)
case "cmd": onCommand(body)
case AccessWire.msgList, AccessWire.msgAudit, AccessWire.msgKeySet, AccessWire.msgAdd, AccessWire.msgWiden,
case InvitationWire.create,InvitationWire.list,InvitationWire.cancel,AccessWire.msgList, AccessWire.msgAudit, AccessWire.msgKeySet, AccessWire.msgAdd, AccessWire.msgWiden,
AccessWire.msgRevoke, AccessWire.msgConfirm:
access.receive(env) { [weak self] reply in
guard let self else { return }
Expand Down
Loading
Loading