Skip to content
Closed
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
3 changes: 2 additions & 1 deletion appleApp/FTW/About/AboutView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ struct AboutView: View {
}

Section {
if !app.isDemo { NavigationLink { CloudSettingsView(app: app) } label: { Label(CloudWords.text("FTW Cloud"), systemImage: "cloud") } }
Link(destination: Origin.privacyURL()) {
Label(AboutWords.text("Privacy policy"), systemImage: "hand.raised")
}
Expand All @@ -90,7 +91,7 @@ struct AboutView: View {
} header: {
Text(AboutWords.text("Privacy and support"))
} footer: {
Text(AboutWords.text("FTW collects nothing about you. Readings and commands travel encrypted between this phone and your box, and Sourceful cannot read them."))
Text(app.site?.cloudOn == true ? CloudWords.text("With FTW Cloud on, Sourceful keeps a copy of your home's readings, signed by your box, to serve this app. Commands travel encrypted between this phone and your box, and Sourceful cannot read them.") : AboutWords.text("FTW collects nothing about you. Readings and commands travel encrypted between this phone and your box, and Sourceful cannot read them."))
}

Section {
Expand Down
73 changes: 73 additions & 0 deletions appleApp/FTW/Cloud/CloudConsentView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import FTWKit
import SwiftUI

/// The one screen, at first start: the app uses FTW Cloud, and statistics
/// are a separate choice, off until ticked. An owner's phone turns FTW Cloud
/// on at the box when the person goes on; a viewer is told to ask the owner.
struct CloudConsentView: View {
let app: AppModel
@Bindable var consent: CloudConsent

private var audience: CloudConsent.Audience {
app.site.map(CloudConsent.audience) ?? .unknown
}

var body: some View {
List {
Section {
VStack(alignment: .leading, spacing: 12) {
Image(systemName: "cloud.fill")
.font(.largeTitle)
.foregroundStyle(Theme.accent)
.symbolEffect(.bounce, value: consent.answered)
.accessibilityHidden(true)
Text(CloudWords.text("The FTW app uses FTW Cloud"))
.font(.title2.weight(.bold))
Text(CloudWords.text("FTW Cloud is part of the service. It keeps a copy of your home, signed by your box, so the app opens at once and stays up to date."))
Text(CloudWords.text("Your box still decides. Commands go straight to it, never through FTW Cloud."))
.foregroundStyle(Theme.fgDim)
}
.padding(.vertical, 4)
}

switch audience {
case .viewer:
Section {
Label(CloudWords.text("Ask the owner of this home to turn on FTW Cloud."), systemImage: "person.crop.circle.badge.questionmark")
}
case .owner, .ownerLater, .unknown:
Section {
Toggle(CloudWords.text("Contribute to statistics"), isOn: $consent.contributeStats)
} footer: {
Text(CloudWords.text("Hourly energy totals, without your name or address, help make FTW better. Off unless you turn it on."))
}
if audience == .ownerLater {
Section {
Label(CloudWords.text("Your box turns on FTW Cloud after its next update."), systemImage: "arrow.down.circle")
}
}
}

Section {
Link(destination: Origin.privacyURL()) {
Label(CloudWords.text("Privacy policy"), systemImage: "hand.raised")
}
} footer: {
Text(CloudWords.text("You can turn FTW Cloud off in About."))
}
}
.scrollContentBackground(.hidden)
.background(Theme.surface)
.safeAreaInset(edge: .bottom) {
Button(CloudWords.text("Continue")) {
Task { await consent.accept(app.site) }
}
.buttonStyle(FTWButtonStyle(kind: .primary))
.padding(16)
.background(Theme.surface)
}
.animation(Theme.spring, value: audience)
.sensoryFeedback(.success, trigger: consent.answered)
.interactiveDismissDisabled()
}
}
35 changes: 35 additions & 0 deletions appleApp/FTW/Cloud/CloudGate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import FTWKit
import SwiftUI

extension View {
/// The FTW Cloud consent, once, over a real home, and the owner's grant
/// at the box once the box can take it. RootView's one line.
func cloudConsent(_ app: AppModel) -> some View {
modifier(CloudGate(app: app))
}
}

private struct CloudGate: ViewModifier {
let app: AppModel
@State private var consent = CloudConsent()

func body(content: Content) -> some View {
content
.sheet(isPresented: Binding(get: { !app.needsPairing && consent.shouldAsk(app.site) }, set: { _ in })) {
CloudConsentView(app: app, consent: consent)
.presentationDragIndicator(.hidden)
.tint(Theme.accent)
}
// When the box answers, an owner who agreed turns FTW Cloud on
// there, once per home.
.task(id: ensureKey) {
guard let site = app.site else { return }
await consent.ensure(site)
}
}

private var ensureKey: String {
guard let site = app.site else { return "" }
return "\(site.siteId ?? "")-\(site.heardFromBox)-\(site.cloudOn)-\(consent.answered)"
Comment on lines +31 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include cloud capability in the consent task identity

When consent is accepted while the box is .ownerLater, this task runs and ensure returns because cloud.link is absent. If that same SiteModel later reconnects after a box update and the new hello adds cloud.link, every component of ensureKey remains unchanged (heardFromBox was already true and cloudOn remains false), so SwiftUI does not rerun the task and the promised automatic grant never happens until the view is recreated. Include the capability/audience in the task ID.

Useful? React with 👍 / 👎.

}
}
91 changes: 91 additions & 0 deletions appleApp/FTW/Cloud/CloudSettingsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import FTWKit
import SwiftUI

/// FTW Cloud in About: on or off, and the receipt once the copy is erased.
/// An owner changes it at the box over Noise; a viewer sees it.
struct CloudSettingsView: View {
let app: AppModel
@State private var consent = CloudConsent()

var body: some View {
List {
if let site = app.site {
switch CloudConsent.audience(site) {
case .owner:
owner(site)
case .ownerLater:
Section { Text(CloudWords.text("Your box can't use FTW Cloud yet.")) }
case .viewer, .unknown:
Section {
Text(CloudWords.text(site.cloudOn ? "FTW Cloud is on for this home." : "FTW Cloud is off for this home."))
} footer: {
Text(CloudWords.text("Only the owner of this home can change this."))
}
}
}
Section {
Link(destination: Origin.privacyURL()) {
Label(CloudWords.text("Privacy policy"), systemImage: "hand.raised")
}
}
}
.scrollContentBackground(.hidden)
.background(Theme.surface)
.navigationTitle(CloudWords.text("FTW Cloud"))
.navigationBarTitleDisplayMode(.inline)
.task {
guard let site = app.site else { return }
await consent.refresh(site)
}
}

@ViewBuilder
private func owner(_ site: SiteModel) -> some View {
let box = consent.box
let on = box?.on ?? site.cloudOn
Section {
Toggle(CloudWords.text("FTW Cloud"), isOn: Binding(get: { on }, set: { want in
Task {
if want { await consent.turnOn(site) } else { await followErasure(site) }
}
}))
.disabled(consent.working || box?.erasing == true)
if on {
Label(CloudWords.text(site.cloudStatus?.isOpen == true || box?.connected == true ? "Connected" : "Not connected"), systemImage: "dot.radiowaves.left.and.right")
.foregroundStyle(Theme.fgDim)
}
if box?.erasing == true {
Label(CloudWords.text("Erasing the copy"), systemImage: "hourglass")
} else if !on, let at = box?.erasedAtMs {
Label(CloudWords.format("Copy erased %@", AppWords.day(at, nowMs: Date().timeIntervalSince1970 * 1000, zone: .current)), systemImage: "checkmark.seal")
.foregroundStyle(Theme.fgDim)
}
} footer: {
VStack(alignment: .leading, spacing: 4) {
Text(CloudWords.text("Turning it off erases the copy in FTW Cloud. Your box shows when it is gone."))
if let help = consent.help { Text(help).foregroundStyle(Theme.importing) }
}
}
if on {
Section {
Toggle(CloudWords.text("Contribute to statistics"), isOn: Binding(get: { box?.stats ?? false }, set: { want in
Task { await consent.setStats(want, site) }
}))
.disabled(consent.working)
} footer: {
Text(CloudWords.text("Hourly energy totals, without your name or address, help make FTW better. Off unless you turn it on."))
}
}
}

/// Off, then the receipt: the box has it once the service confirms the
/// copy is gone, usually within seconds. Asked a few times after the
/// tap, never on a timer of its own.
private func followErasure(_ site: SiteModel) async {
guard await consent.turnOff(site) else { return }
for _ in 0..<5 where consent.box?.erasing == true {
try? await Task.sleep(for: .seconds(2))
await consent.refresh(site)
}
}
}
18 changes: 16 additions & 2 deletions appleApp/FTW/Platform/KeychainStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,27 @@ final class KeychainStore: SecureStore {
private let service = "energy.ftw.app"

func get(_ key: String) -> Bytes? {
(try? read(key)) ?? nil
}

/// Only errSecItemNotFound means absent. Before the first unlock the
/// keychain answers errSecInteractionNotAllowed, and that must never
/// lead to a new key written over the old one.
func read(_ key: String) throws -> Bytes? {
var query = base(key)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else { return nil }
return data.byteArray
switch status {
case errSecSuccess:
guard let data = result as? Data else { throw SecureStoreUnavailable(status: status) }
return data.byteArray
case errSecItemNotFound:
return nil
default:
throw SecureStoreUnavailable(status: status)
}
}

func put(_ key: String, _ value: Bytes) {
Expand Down
31 changes: 5 additions & 26 deletions appleApp/FTW/UI/LaunchClock.swift
Original file line number Diff line number Diff line change
@@ -1,36 +1,15 @@
import Darwin
import os
import FTWKit

/// How long from the process starting to each first thing on screen,
/// logged once each and marked for Instruments. Read them with:
/// logged once each and marked for Instruments: the first frame, the first
/// content, and (from SiteModel) the first live reading. Read them with:
///
/// xcrun simctl spawn <udid> log show --last 2m \
/// --predicate 'subsystem == "energy.ftw.app" AND category == "launch"'
@MainActor
enum LaunchClock {
static let log = Logger(subsystem: "energy.ftw.app", category: "launch")
static let signposter = OSSignposter(logger: log)
private static var seen: Set<String> = []

/// Once per event: the first frame, the first content, the stream.
/// Once per event: the first frame, the first content.
static func mark(_ event: StaticString) {
let name = "\(event)"
guard seen.insert(name).inserted else { return }
signposter.emitEvent(event)
if let ms = sinceProcessStartMs() {
log.notice("\(name, privacy: .public) \(Int(ms.rounded()), privacy: .public) ms after process start")
}
}

/// The kernel's record of when this process started.
static func sinceProcessStartMs() -> Double? {
var info = kinfo_proc()
var size = MemoryLayout<kinfo_proc>.stride
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
guard sysctl(&mib, u_int(mib.count), &info, &size, nil, 0) == 0 else { return nil }
let start = info.kp_proc.p_starttime
var now = timeval()
gettimeofday(&now, nil)
return Double(now.tv_sec - start.tv_sec) * 1000 + Double(now.tv_usec - start.tv_usec) / 1000
ProcessClock.markOnce(event)
}
}
1 change: 1 addition & 0 deletions appleApp/FTW/UI/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ struct RootView: View {
.background(Theme.surface.ignoresSafeArea())
.tint(Theme.accent)
.onAppear { LaunchClock.mark("first frame") }
.cloudConsent(app)
}
}

Expand Down
6 changes: 4 additions & 2 deletions appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -693,13 +693,15 @@ public final class LoopbackCarrier: Carrier {
self.onStatus = onStatus
}

public func send(_ frame: Bytes) {
guard status.isOpen, !closed else { return }
@discardableResult
public func send(_ frame: Bytes) -> Bool {
guard status.isOpen, !closed else { return false }
toBox.append(frame)
scheduler.after(latencyMs) { [weak self] in
guard let self, !self.closed, !self.toBox.isEmpty else { return }
self.box.receive(self.toBox.removeFirst())
}
return true
}

/// Pull to refresh in the demo: the house moves on a second.
Expand Down
6 changes: 5 additions & 1 deletion appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ public enum CarrierStatus: Equatable, Sendable {
public protocol Carrier: AnyObject {
var kind: CarrierKind { get }
var status: CarrierStatus { get }
func send(_ frame: Bytes)
/// Whether the frame went out. A carrier that is not open drops it and
/// says so, so a command can fail at once instead of waiting out a
/// deadline for an answer that cannot come.
@discardableResult
func send(_ frame: Bytes) -> Bool
/// Replaces the handlers. One owner at a time: the carrier stack is
/// built as a chain, each layer owning the one below it.
func setHandlers(onFrame: @escaping @MainActor (Bytes) -> Void, onStatus: @escaping @MainActor (CarrierStatus) -> Void)
Expand Down
Loading
Loading