Skip to content
2 changes: 2 additions & 0 deletions appleApp/FTW.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,7 @@
INFOPLIST_FILE = "FTW-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = FTW;
INFOPLIST_KEY_NSCameraUsageDescription = "FTW uses the camera to read the pairing code from your box.";
INFOPLIST_KEY_NSFaceIDUsageDescription = "FTW uses Face ID when you let an assistant or Sourceful support reach your home.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
Expand Down Expand Up @@ -803,6 +804,7 @@
INFOPLIST_FILE = "FTW-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = FTW;
INFOPLIST_KEY_NSCameraUsageDescription = "FTW uses the camera to read the pairing code from your box.";
INFOPLIST_KEY_NSFaceIDUsageDescription = "FTW uses Face ID when you let an assistant or Sourceful support reach your home.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
Expand Down
34 changes: 34 additions & 0 deletions appleApp/FTW/Access/AccessSettings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import FTWKit
import SwiftUI

/// The Access rows in Settings: who may reach the home, with what waits
/// for the owner, and Get help. Only an owner whose box takes agents sees
/// them.
struct AccessSettingsSection: View {
let app: AppModel
let access: AccessModel

var body: some View {
if access.available {
Section(AccessWords.text("Access")) {
NavigationLink { AccessView(app: app, access: access) } label: {
LabeledContent {
if !access.pending.isEmpty {
Text(AccessWords.format("%@ waiting", String(access.pending.count)))
.foregroundStyle(Theme.ink)
.contentTransition(.numericText())
}
} label: {
Label(AccessWords.text("Assistants and support"), systemImage: "person.badge.key")
}
}
.accessibilityIdentifier("settings.access")
NavigationLink { GetHelpView(app: app, access: access) } label: {
Label(AccessWords.text("Get help"), systemImage: "lifepreserver")
}
.accessibilityIdentifier("settings.help")
}
.task { await access.refresh() }
}
}
}
157 changes: 157 additions & 0 deletions appleApp/FTW/Access/AccessView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import FTWKit
import SwiftUI

/// Who may reach this home other than its phones and watches: the
/// assistants and Sourceful support the owner let in, what they ask for,
/// and what they did. The box decides; this is what it said, and the
/// owner's word back.
struct AccessView: View {
let app: AppModel
let access: AccessModel

var body: some View {
List {
if !access.pending.isEmpty {
Section(AccessWords.text("Waiting for you")) {
ForEach(access.pending) { req in
NavigationLink { RequestView(access: access, request: req) } label: { RequestLine(access: access, request: req) }
.accessibilityIdentifier("access.request.\(req.reqId)")
}
}
}

Section {
ForEach(access.assistants) { row in
NavigationLink { GrantView(app: app, access: access, id: row.id) } label: { GrantLine(access: access, row: row) }
.swipeActions { remove(row) }
.accessibilityIdentifier("access.grant.\(row.id)")
}
if access.list != nil, access.assistants.isEmpty {
Text(AccessWords.text("No assistant can reach your home."))
.foregroundStyle(Theme.ink2)
}
NavigationLink {
OfferFlowView(app: app, flow: OfferFlow(link: nil, lookup: app.offerLookup, keys: app.offerKeys))
} label: {
Label(AccessWords.text("Add an assistant"), systemImage: "plus")
}
.accessibilityIdentifier("access.add")
} header: {
Text(AccessWords.text("Assistants"))
} footer: {
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."))
}

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) }
.swipeActions { remove(row) }
}
NavigationLink { GetHelpView(app: app, access: access) } label: {
Label(AccessWords.text("Get help"), systemImage: "lifepreserver")
}
.accessibilityIdentifier("access.help")
}

Section {
NavigationLink { AuditView(access: access) } label: {
Label(AccessWords.text("Activity"), systemImage: "list.bullet.rectangle")
}
.accessibilityIdentifier("access.activity")
} footer: {
KeyFooter(access: access)
}
}
.redacted(reason: access.list == nil ? .placeholder : [])
.scrollContentBackground(.hidden)
.background(Theme.surface)
.navigationTitle(AccessWords.text("Access"))
.navigationBarTitleDisplayMode(.inline)
.animation(Theme.spring, value: access.list)
.task { await access.refresh() }
.refreshable { await access.refresh() }
}

/// Removing is one tap: no Face ID and no question, since it is always
/// safe.
private func remove(_ row: AgentRow) -> some View {
Button(role: .destructive) {
Task { await access.revoke(row) }
} label: {
Label(AccessWords.text("Remove access"), systemImage: "xmark.circle")
}
}
}

/// One grant in the list: its name, where it lives, what it may do, when it
/// ends and when it last called.
struct GrantLine: View {
let access: AccessModel
let row: AgentRow

var body: some View {
let now = Date().timeIntervalSince1970 * 1000
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline) {
Text(AccessWords.name(row)).font(.body.weight(.semibold)).foregroundStyle(Theme.ink)
Spacer(minLength: 8)
if access.working == row.id { ProgressView().controlSize(.small) }
}
let place = access.place(row)
if !place.isEmpty {
Text(place)
.font(.subheadline)
.foregroundStyle(Theme.ink2)
}
Text("\(AccessWords.summary(row.scopes)) · \(AccessWords.until(row.expiresAtMs, nowMs: now)) · \(AccessWords.lastUsed(row.lastSeenMs, nowMs: now))")
.font(.footnote)
.foregroundStyle(Theme.ink2)
}
.padding(.vertical, 2)
.accessibilityElement(children: .combine)
}
}

/// A request in the list: who asks for what, and until when it waits.
struct RequestLine: View {
let access: AccessModel
let request: AgentRequest

var body: some View {
let now = Date().timeIntervalSince1970 * 1000
HStack(alignment: .center, spacing: 12) {
Image(systemName: "hand.raised.fill")
.foregroundStyle(Theme.ink)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 3) {
Text(AccessWords.format("%@ asks to", access.agentName(request.agent)))
.font(.subheadline)
.foregroundStyle(Theme.ink2)
Text(AccessWords.op(request.op)).font(.body.weight(.semibold)).foregroundStyle(Theme.ink)
Text(AccessWords.format("Waits %@", AppWords.day(Double(request.expiresAtMs), nowMs: now, zone: .current)))
.font(.footnote)
.foregroundStyle(Theme.ink2)
}
Spacer(minLength: 0)
}
.contentShape(Rectangle())
}
}

/// How this phone approves: Face ID, a key yet to be made, or a key Face ID
/// voided.
struct KeyFooter: View {
let access: AccessModel

var body: some View {
VStack(alignment: .leading, spacing: 6) {
// Only the box's own list says whether it holds this phone's key.
if access.keysKnown {
Text(AccessWords.format(access.boxHasKey ? "Approvals on this phone use %@." : "This phone makes its approval key the first time you approve, under the same %@.", AccessWords.biometry))
}
if let help = access.help {
Text(help).foregroundStyle(Theme.alarm)
}
}
}
}
Loading
Loading