diff --git a/appleApp/FTW.xcconfig b/appleApp/FTW.xcconfig index a355fd35..231edb58 100644 --- a/appleApp/FTW.xcconfig +++ b/appleApp/FTW.xcconfig @@ -9,7 +9,7 @@ FTW_APP_GROUP = group.energy.ftw.app // An extension's version must match its app's. -MARKETING_VERSION = 0.4.9 +MARKETING_VERSION = 0.4.10 CURRENT_PROJECT_VERSION = 1 IPHONEOS_DEPLOYMENT_TARGET = 26.0 diff --git a/appleApp/FTW/About/AboutView.swift b/appleApp/FTW/About/AboutView.swift index 87678dd1..6c31588a 100644 --- a/appleApp/FTW/About/AboutView.swift +++ b/appleApp/FTW/About/AboutView.swift @@ -5,7 +5,7 @@ import SwiftUI /// the license texts the build copied in from the repository root. struct AboutView: View { enum Start: Hashable, Identifiable { - case about, remove + case about, remove, statistics, cloudTerms case document(LicenseText.Document) var id: Self { self } @@ -14,6 +14,8 @@ struct AboutView: View { switch name { case "about": self = .about case "remove": self = .remove + case "statistics": self = .statistics + case "cloud-terms": self = .cloudTerms default: guard let document = LicenseText.Document(rawValue: name) else { return nil } self = .document(document) @@ -24,10 +26,13 @@ struct AboutView: View { enum Route: Hashable { case document(LicenseText.Document) case remove + case statistics + case cloudTerms } let app: AppModel @State private var path: [Route] + @State private var reviewConsent = CloudConsent() @Environment(\.dismiss) private var dismiss @Environment(\.dynamicTypeSize) private var typeSize @ScaledMetric(relativeTo: .title2) private var markSize: CGFloat = 52 @@ -38,6 +43,8 @@ struct AboutView: View { switch start { case .about: _path = State(initialValue: []) case .remove: _path = State(initialValue: [.remove]) + case .statistics: _path = State(initialValue: [.statistics]) + case .cloudTerms: _path = State(initialValue: [.cloudTerms]) case .document(let document): _path = State(initialValue: [.document(document)]) } } @@ -134,6 +141,10 @@ struct AboutView: View { switch route { case .document(let document): LicenseView(document: document, source: source) + case .statistics: + CloudSettingsView(app:app) + case .cloudTerms: + CloudConsentView(app:app, consent:reviewConsent) case .remove: RemovePhoneView(app: app) { dismiss() } } diff --git a/appleApp/FTW/Cloud/CloudConsentView.swift b/appleApp/FTW/Cloud/CloudConsentView.swift index 36616ab9..952be544 100644 --- a/appleApp/FTW/Cloud/CloudConsentView.swift +++ b/appleApp/FTW/Cloud/CloudConsentView.swift @@ -1,12 +1,13 @@ 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. +/// Current Cloud terms at first start. Statistics come with Cloud; +/// an owner grants the scope, while a viewer accepts it for this phone. struct CloudConsentView: View { let app: AppModel @Bindable var consent: CloudConsent + var onPausedChoice: (() -> Void)? = nil + @Environment(\.dynamicTypeSize) private var typeSize private var audience: CloudConsent.Audience { app.site.map(CloudConsent.audience) ?? .unknown @@ -24,28 +25,26 @@ struct CloudConsentView: View { 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.")) + Text(CloudWords.text("Your box still decides. Commands stay encrypted between this phone and your box.")) .foregroundStyle(Theme.fgDim) } .padding(.vertical, 4) } - switch audience { - case .viewer: + Section { + Text(CloudWords.text(CloudConsent.included)) + Text(CloudWords.text(CloudConsent.statisticsDetails)) + Text(CloudWords.text(CloudConsent.statisticsPrivacy)) + if !consent.statisticsReady { Text(CloudWords.text(CloudConsent.statisticsWaiting)) } + if let help = consent.help { Text(help).foregroundStyle(Theme.alarm) } + } + if audience == .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: + } else if audience == .ownerLater { Section { - Toggle(CloudWords.text("Contribute to statistics"), isOn: $consent.contributeStats) - .tint(Theme.live) - } 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") - } + Label(CloudWords.text("Update your box before accepting the new Cloud terms."), systemImage: "arrow.down.circle") } } @@ -56,19 +55,41 @@ struct CloudConsentView: View { } footer: { Text(CloudWords.text("You can turn FTW Cloud off in About.")) } + if typeSize.isAccessibilitySize { Section { actions } } } .scrollContentBackground(.hidden) .background(Theme.surface) .safeAreaInset(edge: .bottom) { - Button(CloudWords.text("Continue")) { - Task { await consent.accept(app.site) } + if !typeSize.isAccessibilitySize { + actions.padding(16).background(Theme.surface) } - .buttonStyle(FTWButtonStyle(kind: .primary)) - .padding(16) - .background(Theme.surface) + } + .task(id: audience) { + if !app.cloudPaused, let site = app.site, audience == .owner { await consent.refreshStatisticsPolicy(site) } } .animation(Theme.spring, value: audience) .sensoryFeedback(.success, trigger: consent.answered) - .interactiveDismissDisabled() + .interactiveDismissDisabled(!app.cloudPaused) + } + + private var actions: some View { + VStack(spacing: 8) { + Button(CloudWords.text("Accept Cloud terms")) { + if app.cloudPaused, let id=app.site?.siteId { + consent.prepareReconnect(id) + onPausedChoice?() + app.resumeCloud() + } else { Task { await consent.accept(app.site) } } + } + .buttonStyle(FTWButtonStyle(kind: .primary)) + .disabled(consent.working || (!app.cloudPaused && (audience == .unknown || audience == .ownerLater || (audience == .owner && !consent.policyAvailable)))) + Button(CloudWords.text("Use the box locally")) { + if let id=app.site?.siteId { consent.cancelReconnect(id) } + app.pauseCloud() + onPausedChoice?() + } + .buttonStyle(FTWButtonStyle(kind: .quiet)) + } } + } diff --git a/appleApp/FTW/Cloud/CloudGate.swift b/appleApp/FTW/Cloud/CloudGate.swift index 0f70688c..96166520 100644 --- a/appleApp/FTW/Cloud/CloudGate.swift +++ b/appleApp/FTW/Cloud/CloudGate.swift @@ -2,8 +2,8 @@ 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. + /// The current Cloud terms, accepted for each real home before its owner + /// grants the new scope. Opening a screen never creates a grant. func cloudConsent(_ app: AppModel) -> some View { modifier(CloudGate(app: app)) } @@ -12,24 +12,34 @@ extension View { private struct CloudGate: ViewModifier { let app: AppModel @State private var consent = CloudConsent() + @State private var reviewingPaused = false func body(content: Content) -> some View { - content - .sheet(isPresented: Binding(get: { !app.needsPairing && consent.shouldAsk(app.site) }, set: { _ in })) { - CloudConsentView(app: app, consent: consent) + Group { + if app.cloudPaused && !app.needsPairing { + ContentUnavailableView { + Label(CloudWords.text("FTW Cloud"), systemImage: "cloud.slash") + } description: { + Text(CloudWords.text("This phone has stopped using FTW Cloud. Your box keeps working locally.")) + } actions: { + Button(CloudWords.text("Review Cloud terms")) { reviewingPaused = true } + Menu(CloudWords.text("Homes")) { + ForEach(app.homes) { home in + Button(HomeName.title(home.label)) { app.open(home.siteId) } + } + Button(CloudWords.text("Pair a home")) { app.recovering = true } + } + } + } else { content } + } + .sheet(isPresented: Binding(get: { reviewingPaused || (!app.cloudPaused && !app.needsPairing && consent.shouldAsk(app.site)) }, set: { shown in if !shown { reviewingPaused=false } })) { + CloudConsentView(app: app, consent: consent, onPausedChoice: { reviewingPaused=false }) .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) + .task(id: "\(app.site?.siteId ?? "")-\(app.site?.heardFromBox ?? false)-\(app.cloudPaused)") { + guard !app.cloudPaused, let site=app.site else { return } + await consent.finishPendingAcceptance(site) } } - - private var ensureKey: String { - guard let site = app.site else { return "" } - return "\(site.siteId ?? "")-\(site.heardFromBox)-\(site.cloudOn)-\(consent.answered)" - } } diff --git a/appleApp/FTW/Cloud/CloudSettingsView.swift b/appleApp/FTW/Cloud/CloudSettingsView.swift index e837f983..c4be4ce9 100644 --- a/appleApp/FTW/Cloud/CloudSettingsView.swift +++ b/appleApp/FTW/Cloud/CloudSettingsView.swift @@ -1,14 +1,18 @@ import FTWKit import SwiftUI -/// FTW Cloud in About: on or off, and the receipt once the copy is erased. +/// FTW Cloud in About: current terms and one choice for the whole service. /// An owner changes it at the box over Noise; a viewer sees it. struct CloudSettingsView: View { let app: AppModel @State private var consent = CloudConsent() + @State private var stopping = false var body: some View { List { + if app.isDemo { + Section { Text(CloudWords.text("Demo: no reports leave this phone.")) } + } if let site = app.site { switch CloudConsent.audience(site) { case .owner: @@ -33,8 +37,16 @@ struct CloudSettingsView: View { .background(Theme.surface) .navigationTitle(CloudWords.text("FTW Cloud")) .navigationBarTitleDisplayMode(.inline) - .task { - guard let site = app.site else { return } + .confirmationDialog(CloudWords.text("Turn FTW Cloud off?"), isPresented: $stopping, titleVisibility: .visible) { + Button(CloudWords.text("Turn off"), role: .destructive) { + Task { if let site = app.site, await consent.turnOff(site) { app.pauseCloud() } } + } + Button(CloudWords.text("Cancel"), role: .cancel) { } + } message: { + Text(CloudWords.text("Turning FTW Cloud off stops remote access for phones and assistants. The box keeps working locally. You may need to pair phones again later. Check the local box for the deletion receipt.")) + } + .task(id: app.site.map(CloudConsent.audience)) { + guard let site = app.site, CloudConsent.audience(site) == .owner else { return } await consent.refresh(site) } } @@ -46,7 +58,7 @@ struct CloudSettingsView: View { Section { Toggle(CloudWords.text("FTW Cloud"), isOn: Binding(get: { on }, set: { want in Task { - if want { await consent.turnOn(site) } else { await followErasure(site) } + if want { await consent.turnOn(site) } else { stopping = true } } })) .tint(Theme.live) @@ -71,19 +83,21 @@ struct CloudSettingsView: View { } } 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.")) + Text(CloudWords.text("Turning it off ends remote access and requests deletion. Check the local box for the receipt.")) if let help = consent.help { Text(help).foregroundStyle(Theme.alarm) } } } if on { Section { - Toggle(CloudWords.text("Contribute to statistics"), isOn: Binding(get: { box?.stats ?? false }, set: { want in - Task { await consent.setStats(want, site) } - })) - .tint(Theme.live) - .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.")) + Label(CloudWords.text("Statistics are included with FTW Cloud"), systemImage: "chart.bar") + if box?.termsAccepted != true { + Button(CloudWords.text("Accept Cloud terms")) { Task { await consent.accept(site) } } + .disabled(consent.working || !consent.policyAvailable) + } + Text(CloudWords.text(CloudConsent.included)) + Text(CloudWords.text(CloudConsent.statisticsDetails)) + Text(CloudWords.text(CloudConsent.statisticsPrivacy)) + if !consent.statisticsReady { Text(CloudWords.text(CloudConsent.statisticsWaiting)) } } } } @@ -93,14 +107,4 @@ struct CloudSettingsView: View { transport == Contract.transportQUIC ? "QUIC" : "WebSocket" } - /// 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) - } - } } diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index e625cfef..57ac36d7 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -105,6 +105,10 @@ public final class SimulatedBox { /// A box linked to FTW Cloud: hello carries its session information, /// with the table of opaque car ids for the cars it keeps then. public var linkedToCloud = false + public var statisticsAvailable = true + private var statisticsOn = false + private var cloudCopyOn = true + private var cloudTermsAccepted = false /// Every hello, and every sub, as they came. public private(set) var hellos = 0 public private(set) var subs = 0 @@ -212,7 +216,7 @@ public final class SimulatedBox { ("mode", .text(booting ? "booting" : "full")), ("box", .map([("id", .text("demo-box")), ("build", .text("0.131.0-demo")), ("tz", .text(house.timeZone.identifier))])), ("clock", .map([("source", .text("ntp")), ("syncedAtMs", .number(scheduler.nowMs - 60_000)), ("uptimeMs", .number(uptimeMs))])), - ("caps", .array((Self.caps + moreCaps + (cars == nil ? [] : [Contract.capDerCar])).map { .text($0) })), + ("caps", .array((Self.caps + [Contract.capCloudLink] + moreCaps + (cars == nil ? [] : [Contract.capDerCar])).map { .text($0) })), ("capsHash", .text("demo")), ("modes", .array(Self.modes.map { .map([("key", .text($0.key)), ("label", .text($0.label)), ("tooltip", .text($0.tooltip)), ("tier", .text($0.tier))]) })), ("role", .text(Contract.roleOwner)), @@ -468,6 +472,13 @@ public final class SimulatedBox { /// Priced beside the route, never from the method, as the box does. static let routes: [(method: String, path: String, tier: Tier, op: String?)] = [ ("GET", "/api/status", .read, nil), + ("GET", "/api/cloud", .configure, nil), + ("DELETE", "/api/cloud", .configure, nil), + ("GET", "/api/cloud/stats-policy", .configure, nil), + ("POST", "/api/cloud/grant", .configure, nil), + ("DELETE", "/api/cloud/grant", .configure, nil), + ("POST", "/api/cloud/stats", .configure, nil), + ("DELETE", "/api/cloud/stats", .configure, nil), ("GET", "/api/energy/daily", .read, nil), ("GET", "/api/savings/daily", .read, nil), ("GET", "/api/loadpoints", .read, nil), @@ -574,9 +585,43 @@ public final class SimulatedBox { return [battery, car] } + private func cloudConsentState() -> JSON { + [ + "state": .string(cloudCopyOn ? "on" : "off"), "connected": true, + "cloudPolicy": .string(CloudConsent.policy), "cloudTermsAccepted": .bool(cloudTermsAccepted), + "statisticsPolicy": .string(CloudConsent.statsPolicy), + "statisticsActive": .bool(statisticsOn), "statisticsReady": .bool(statisticsAvailable), + ] + } + private func answer(_ method: String, _ pattern: String, _ itemID: String?, _ query: [String: String], _ body: JSON?) -> (Int, JSON) { let now = scheduler.nowMs switch (method, pattern) { + case ("GET", "/api/cloud/stats-policy"): + return (200, ["policy": .string(CloudConsent.statsPolicy), "cloud_policy": .string(CloudConsent.policy), "cloud_includes_statistics": true, "available": .bool(statisticsAvailable)]) + case ("GET", "/api/cloud"): + return (200, cloudConsentState()) + case ("POST", "/api/cloud/grant"): + guard body?["policy"]?.string == CloudConsent.policy else { return (400, ["code": "E_UNAVAILABLE"]) } + cloudCopyOn = true + cloudTermsAccepted = true + statisticsOn = true + return (200, cloudConsentState()) + case ("POST", "/api/cloud/stats"): + guard statisticsAvailable, body?["policy"]?.string == CloudConsent.statsPolicy else { + return (409, ["code": "E_UNAVAILABLE"]) + } + statisticsOn = true + return (200, cloudConsentState()) + case ("DELETE", "/api/cloud/stats"): + guard !cloudTermsAccepted else { return (409, ["code": "E_UNAVAILABLE"]) } + statisticsOn = false + return (200, cloudConsentState()) + case ("DELETE", "/api/cloud"), ("DELETE", "/api/cloud/grant"): + cloudCopyOn = false + cloudTermsAccepted = false + statisticsOn = false + return (200, cloudConsentState()) case ("GET", "/api/status"): let perPhase = house.gridW / 3 return (200, [ diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift index 8db09e65..9d3764ce 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift @@ -1,175 +1,147 @@ import Foundation import Observation -/// FTW Cloud as the person agrees to it, and as the box holds it. -/// -/// The app uses FTW Cloud as part of the service, so the answer is given -/// once, when the person starts using the app, on one screen. Statistics are -/// a separate box on that screen, off until ticked. The grant itself lives on -/// the box: an owner's phone turns it on there over its Noise session, the -/// first time, and an owner can turn it off again from About. A viewer cannot -/// change it and is told to ask the owner. +/// The current FTW Cloud terms. Statistics are part of Cloud; the box owns +/// the accepted version. A local-only statistics choice lives on the box UI. @Observable @MainActor public final class CloudConsent { - /// The consent texts the person read. The box records the names. - public static let policy = "ftw-cloud-2026-10" - public static let statsPolicy = "ftw-stats-2026-10" + nonisolated public static let policy = "ftw-cloud-2026-10-v2" + nonisolated public static let statsPolicy = "ftw-project-stats-2026-10-v2" + nonisolated public static let statisticsDetails = "Recorded hourly energy, configured battery capacity, battery and charger power limits, and solar capacity help Sourceful improve FTW and publish group totals. Values are rounded to 10 Wh or W. No names, addresses or device ids." + nonisolated public static let statisticsPrivacy = "Sourceful links reports to your Cloud home for deletion. They are not anonymous and stay for 13 months. Public values need at least 20 homes, with no home above 30% of a sum. Turning FTW Cloud off deletes unpublished reports when the box reaches Cloud; published totals remain." + nonisolated public static let statisticsWaiting = "Hourly collection waits for privacy review and policy activation. Your choice can be saved now; no hourly report leaves before approval." + nonisolated public static let included = "Everyone using FTW Cloud contributes to project statistics. There is no separate statistics switch. If you do not want to contribute, use FTW locally without Cloud. Local users can choose to share statistics without enabling remote access." - /// What the box says about FTW Cloud, for an owner. public struct BoxState: Equatable, Sendable { - /// off, on or revoking. public var state: String public var stats: Bool + public var termsAccepted: Bool public var connected: Bool - /// When the service confirmed it erased the home's copy. public var erasedAtMs: Double? - public var on: Bool { state == "on" } public var erasing: Bool { state == "revoking" } - init(_ json: JSON) { state = json["state"]?.string ?? "off" - stats = (json["purposes"]?.array ?? []).contains { $0.string == Contract.cloudPurposeStats } + stats = json["statisticsPolicy"]?.string == CloudConsent.statsPolicy && json["statisticsActive"]?.bool == true + termsAccepted = json["cloudPolicy"]?.string == CloudConsent.policy && json["cloudTermsAccepted"]?.bool == true connected = json["connected"]?.bool ?? false erasedAtMs = json["purgedAtMs"]?.number.flatMap { $0 > 0 ? $0 : nil } } } - - /// Whom the screen speaks to. - public enum Audience: Equatable, Sendable { - /// An owner whose box can run FTW Cloud. - case owner - /// An owner whose box cannot yet: it turns FTW Cloud on after an - /// update. - case ownerLater - case viewer - /// The box has not answered yet. - case unknown - } - + public enum Audience: Hashable, Sendable { case owner, ownerLater, viewer, unknown } public private(set) var answered: Bool - /// The statistics box. Off until the person ticks it. - public var contributeStats = false + public private(set) var acceptedHome: String? public private(set) var box: BoxState? public private(set) var working = false public private(set) var help: String? - + public private(set) var statisticsReady = false + public private(set) var policyAvailable = false @ObservationIgnored private let defaults: UserDefaults public init(defaults: UserDefaults = .standard) { self.defaults = defaults answered = defaults.string(forKey: Keys.answered) == Self.policy - contributeStats = defaults.bool(forKey: Keys.stats) } - - /// The one screen, once, on a real home. public func shouldAsk(_ site: SiteModel?) -> Bool { guard let site, !site.isDemo, site.siteId != nil else { return false } - return !answered + let accepted = acceptedHome == site.siteId || defaults.string(forKey: Keys.asked(site.siteId!)) == Self.policy + return !answered || !accepted || defaults.string(forKey: Keys.pending(site.siteId!)) == Self.policy } - public static func audience(_ site: SiteModel) -> Audience { guard site.heardFromBox else { return .unknown } guard site.canConfigure else { return .viewer } return site.session.caps.contains(Contract.capCloudLink) ? .owner : .ownerLater } - - /// The person read the screen and went on. An owner's phone turns FTW - /// Cloud on at the box now, or once the box can. - public func accept(_ site: SiteModel?) async { + @discardableResult + public func accept(_ site: SiteModel?) async -> Bool { + guard let site else { return false } + switch Self.audience(site) { + case .owner: + guard await turnOn(site) else { return false } + case .viewer: + // This phone accepts the terms; only an owner can grant the + // home's scope. Cloud enforces that scope for the whole home. + break + case .ownerLater, .unknown: + help = CloudWords.text("Update your box before accepting the new Cloud terms.") + return false + } + if let id = site.siteId { + defaults.set(Self.policy, forKey: Keys.asked(id)) + defaults.removeObject(forKey: Keys.pending(id)) + } defaults.set(Self.policy, forKey: Keys.answered) - defaults.set(contributeStats, forKey: Keys.stats) + acceptedHome = site.siteId answered = true - guard let site else { return } - await ensure(site) + return true } - - /// Turn FTW Cloud on at the box for an owner who agreed, once per home. - /// After that the box's word stands: if someone turned it off, on the box - /// or in About, it stays off until an owner turns it on again. - public func ensure(_ site: SiteModel) async { - guard answered, let siteId = site.siteId, !site.isDemo, Self.audience(site) == .owner, - !defaults.bool(forKey: Keys.asked(siteId)) else { return } - // Already on: nothing to ask for, now or later. - if site.cloudOn { - defaults.set(true, forKey: Keys.asked(siteId)) - return - } - if await turnOn(site) { - defaults.set(true, forKey: Keys.asked(siteId)) - } + public func review() { + defaults.removeObject(forKey: Keys.answered) + answered = false + } + /// Reading terms never reconnects. Only the explicit accept action saves + /// this per-home intent; fresh box policy is checked after reconnecting. + public func prepareReconnect(_ id: String) { defaults.set(Self.policy,forKey:Keys.pending(id)) } + public func cancelReconnect(_ id: String) { defaults.removeObject(forKey:Keys.pending(id)) } + public func finishPendingAcceptance(_ site: SiteModel) async { + guard let id=site.siteId, site.heardFromBox, !working, + defaults.string(forKey:Keys.pending(id))==Self.policy else { return } + working=true + defer { working=false } + _ = await accept(site) } - - /// What the box holds now. Owners only; a viewer's phone may not ask. public func refresh(_ site: SiteModel) async { guard site.canConfigure else { return } do { box = try await site.callBox(.get, "/api/cloud").map(BoxState.init) + await refreshStatisticsPolicy(site) help = nil - } catch let e as BoxAPIError { - help = e.help - } catch { - help = nil - } + } catch let e as BoxAPIError { help = e.help } + catch { help = CloudWords.text("The choice could not be saved. Check the box status before trying again.") } } - - /// FTW Cloud on, with statistics if the person agreed to them. @discardableResult public func turnOn(_ site: SiteModel) async -> Bool { - await act { + guard site.canConfigure else { return false } + await refreshStatisticsPolicy(site) + guard policyAvailable else { + help = CloudWords.text("Update your box before accepting the new Cloud terms.") + return false + } + return await act { let scopes = JSON.array(Contract.cloudScopes.map { .string($0) }) - let granted = try await site.callBox(.post, "/api/cloud/grant", body: .object([("scopes", scopes), ("policy", .string(Self.policy))])) - box = granted.map(BoxState.init) - if contributeStats { - box = try await site.callBox(.post, "/api/cloud/stats", body: .object([("policy", .string(Self.statsPolicy))])).map(BoxState.init) - } - if let siteId = site.siteId { defaults.set(true, forKey: Keys.asked(siteId)) } + box = try await site.callBox(.post, "/api/cloud/grant", body: .object([("scopes", scopes), ("policy", .string(Self.policy))])).map(BoxState.init) + if let id = site.siteId { defaults.set(Self.policy, forKey: Keys.asked(id)) } } } - - /// FTW Cloud off. The box asks the service to erase its copy and shows - /// the receipt once it has one. + /// This ends all Cloud functions, rather than opting out of statistics. + /// A remote connection can close before its deletion receipt arrives; + /// the local box remains the place to verify that receipt. @discardableResult public func turnOff(_ site: SiteModel) async -> Bool { - // Off by hand is the person's word: the app never turns it on again - // by itself. - if let siteId = site.siteId { defaults.set(true, forKey: Keys.asked(siteId)) } - return await act { - box = try await site.callBox(.delete, "/api/cloud/grant").map(BoxState.init) - } + if let id = site.siteId { defaults.set(Self.policy, forKey: Keys.asked(id)) } + return await act { box = try await site.callBox(.delete, "/api/cloud").map(BoxState.init) } } - - @discardableResult - public func setStats(_ on: Bool, _ site: SiteModel) async -> Bool { - contributeStats = on - defaults.set(on, forKey: Keys.stats) - return await act { - box = on - ? try await site.callBox(.post, "/api/cloud/stats", body: .object([("policy", .string(Self.statsPolicy))])).map(BoxState.init) - : try await site.callBox(.delete, "/api/cloud/stats").map(BoxState.init) - } + public func refreshStatisticsPolicy(_ site: SiteModel) async { + guard site.canConfigure else { policyAvailable = false; statisticsReady = false; return } + do { + let p = try await site.callBox(.get, "/api/cloud/stats-policy") + policyAvailable = p?["policy"]?.string == Self.statsPolicy && p?["cloud_policy"]?.string == Self.policy && p?["cloud_includes_statistics"]?.bool == true + statisticsReady = policyAvailable && p?["available"]?.bool == true + } catch { policyAvailable = false; statisticsReady = false } } - private func act(_ work: () async throws -> Void) async -> Bool { working = true defer { working = false } - do { - try await work() - help = nil - return true - } catch let e as BoxAPIError { - help = e.help - } catch { - help = CloudWords.text("That didn't work. Nothing on your box has changed.") - } + do { try await work(); help = nil; return true } + catch let e as BoxAPIError { help = e.help } + catch { help = CloudWords.text("The choice could not be saved. Check the box status before trying again.") } return false } - enum Keys { static let answered = "FTWCloudConsent" - static let stats = "FTWCloudStats" - static func asked(_ siteId: String) -> String { "FTWCloudAsked-\(siteId)" } + static func asked(_ id: String) -> String { "FTWCloudAsked-\(id)" } + static func pending(_ id: String) -> String { "FTWCloudPending-\(id)" } } } @@ -190,11 +162,30 @@ public enum CloudWords { } static let sv: [String: String] = [ + "Demo: no reports leave this phone.": "Demo: inga rapporter lämnar telefonen.", + CloudConsent.statisticsDetails: "Dela registrerad timenergi, inställd batterikapacitet, effektgränser för batterier och laddare samt solkapacitet med Sourceful för att förbättra FTW och publicera gruppsummor. Värden avrundas till 10 Wh eller W. Inga namn, adresser eller enhets-id:n.", + CloudConsent.statisticsPrivacy: "Sourceful kopplar rapporterna till ditt Cloud-hem för att kunna radera dem. De är inte anonyma och sparas i 13 månader. Publika värden kräver minst 20 hem och att inget hem står för mer än 30 procent av summan. Stänger du av FTW Cloud raderas opublicerade rapporter när boxen når Cloud; publicerade gruppsummor finns kvar.", + CloudConsent.statisticsWaiting: "Timinsamlingen väntar på integritetsgranskning och aktivering. Ditt val kan sparas nu; ingen timrapport lämnar boxen före godkännandet.", + CloudConsent.included: "Alla som använder FTW Cloud bidrar till projektstatistik. Det finns inget separat statistikval. Vill du inte bidra använder du FTW lokalt utan Cloud. Lokala användare kan välja att dela statistik utan att slå på fjärråtkomst.", + "Accept Cloud terms": "Godkänn Cloud-villkoren", + "Homes": "Hem", + "Pair a home": "Parkoppla ett hem", + "Use the box locally": "Använd boxen lokalt", + "Statistics are included with FTW Cloud": "Statistik ingår i FTW Cloud", + "Update your box before accepting the new Cloud terms.": "Uppdatera boxen innan du godkänner de nya Cloud-villkoren.", + "This phone has stopped using FTW Cloud. Your box keeps working locally.": "Den här telefonen har slutat använda FTW Cloud. Boxen fungerar vidare lokalt.", + "Review Cloud terms": "Läs Cloud-villkoren", + "Turning FTW Cloud off stops remote access for phones and assistants. The box keeps working locally. You may need to pair phones again later. Check the local box for the deletion receipt.": "Stänger du av FTW Cloud förlorar telefoner och assistenter sin fjärråtkomst. Boxen fungerar vidare lokalt. Du kan behöva parkoppla telefonerna igen senare. Boxens lokala sida visar kvittot på radering.", + "Turn FTW Cloud off?": "Stänga av FTW Cloud?", + "Turn off": "Stäng av", + "Cancel": "Avbryt", + "Review the new statistics policy": "Läs den nya statistikpolicyn", + "The FTW app uses FTW Cloud": "FTW-appen använder FTW Cloud", "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.": "FTW Cloud ingår i tjänsten. Den håller en kopia av ditt hem, signerad av din box, så att appen öppnar direkt och håller sig uppdaterad.", - "Your box still decides. Commands go straight to it, never through FTW Cloud.": - "Boxen bestämmer fortfarande. Kommandon går direkt till den, aldrig via FTW Cloud.", + "Your box still decides. Commands stay encrypted between this phone and your box.": + "Boxen bestämmer fortfarande. Kommandon är krypterade mellan telefonen och boxen.", "Contribute to statistics": "Bidra till statistik", "Hourly energy totals, without your name or address, help make FTW better. Off unless you turn it on.": "Energisummor per timme, utan namn eller adress, hjälper oss att göra FTW bättre. Av tills du slår på det.", @@ -203,7 +194,7 @@ public enum CloudWords { "Continue": "Fortsätt", "Privacy policy": "Integritetspolicy", "You can turn FTW Cloud off in About.": "Du kan stänga av FTW Cloud under Om.", - "That didn't work. Nothing on your box has changed.": "Det gick inte. Inget på din box har ändrats.", + "The choice could not be saved. Check the box status before trying again.": "Valet kunde inte sparas. Kontrollera boxens status innan du försöker igen.", "FTW Cloud": "FTW Cloud", "On": "På", "Off": "Av", @@ -215,8 +206,8 @@ public enum CloudWords { "FTW Cloud is on for this home.": "FTW Cloud är på för det här hemmet.", "FTW Cloud is off for this home.": "FTW Cloud är av för det här hemmet.", "Your box can't use FTW Cloud yet.": "Din box kan inte använda FTW Cloud än.", - "Turning it off erases the copy in FTW Cloud. Your box shows when it is gone.": - "Stänger du av raderas kopian i FTW Cloud. Boxen visar när den är borta.", + "Turning it off ends remote access and requests deletion. Check the local box for the receipt.": + "Stänger du av upphör fjärråtkomst och boxen begär radering. Boxens lokala sida visar kvittot.", "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.": "Med FTW Cloud på håller Sourceful en kopia av hemmets mätvärden, signerad av din box, för den här appen. Kommandon går krypterade mellan telefonen och boxen, och Sourceful kan inte läsa dem.", ] diff --git a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift index 55330240..742b79da 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift @@ -140,6 +140,21 @@ public final class AppModel { } public var site: SiteModel? { home?.site } + public private(set) var cloudPaused = false + public func pauseCloud() { + guard let id = site?.siteId else { return } + sites.update(id) { $0.cloudPaused = true } + cloudPaused = true + connectGeneration += 1 + site?.destroy() + connectHelp = CloudWords.text("This phone has stopped using FTW Cloud. Your box keeps working locally.") + } + public func resumeCloud() { + guard let id = site?.siteId else { return } + sites.update(id) { $0.cloudPaused = false } + cloudPaused = false + open(id) + } /// Nothing paired and nothing to show: the only screen is pairing. public var needsPairing: Bool { home == nil || recovering || offeredLink != nil } @@ -153,6 +168,7 @@ public final class AppModel { /// Point the app at a home and connect to it. public func open(_ siteId: String) { + cloudPaused = sites.get(siteId)?.cloudPaused ?? false exitDemo() home?.site.destroy() let site = SiteModel(siteId: siteId, build: build, ua: ua, scheduler: scheduler, files: files, shared: shared) @@ -182,6 +198,7 @@ public final class AppModel { /// privilege; a device enrolled before the local copy pays one last /// prompt, and never again. public func connect() async { + guard !cloudPaused else { return } guard let site = home?.site, let siteId = site.siteId else { return } connectGeneration += 1 let mine = connectGeneration @@ -237,6 +254,7 @@ public final class AppModel { /// Pull to refresh: a fresh stream in place, or a new carrier if there /// was none. public func refresh() async { + guard !cloudPaused else { return } if isDemo { site?.refresh() return @@ -251,6 +269,7 @@ public final class AppModel { /// Whether anyone is looking. Coming back from the background builds a /// new carrier, because `suspend` closed the old one. public func setVisible(_ visible: Bool) { + guard !cloudPaused else { return } guard let site else { return } site.setVisible(visible) if visible, suspended { @@ -285,6 +304,7 @@ public final class AppModel { } public func networkOnline() { + guard !cloudPaused else { return } site?.networkOnline() } diff --git a/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift b/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift index 86454cb0..5a7b7c2e 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift @@ -12,6 +12,8 @@ public struct StoredSite: Codable, Equatable, Sendable, Identifiable { /// Single use, spent in the first handshake. Kept until then. public var pairingCode: Data? public var lanHint: String? + /// A local choice on this phone, kept across restarts. + public var cloudPaused: Bool? = nil /// The home id at FTW Cloud, where the box session is reached without /// the relay. From a v3 QR, or from the box at its first hello. Nil for /// a box that has not linked. diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift index d033660a..ecaab493 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift @@ -26,6 +26,26 @@ import FTWDemo for _ in 0..<10 { await Task.yield() } } + @Test func demoStatisticsUsesTheRealSettingsRoutesWithoutSendingReports() async throws { + let (_, home, s) = await demo() + let d = try #require(UserDefaults(suiteName: "ftw-demo-consent-\(UUID().uuidString)")) + let c = CloudConsent(defaults: d) + let refresh = Task { await c.refresh(home.site) } + await run(s, 1_000) + await refresh.value + #expect(c.box?.on == true) + #expect(c.statisticsReady) + #expect(c.box?.stats == false) + let enable = Task { await c.accept(home.site) } + await run(s, 1_000) + #expect(await enable.value) + #expect(c.box?.stats == true && c.box?.termsAccepted == true) + let disable = Task { await c.turnOff(home.site) } + await run(s, 1_000) + #expect(await disable.value) + #expect(c.box?.stats == false) + } + @Test func theDemoStreamsAndSaysItIsLive() async { let (app, home, s) = await demo() #expect(app.isDemo) @@ -134,6 +154,43 @@ import FTWDemo } } + @Test func decliningCloudOnThisPhoneSurvivesRestartWithoutOpeningASocket() async throws { + let world = World() + let app = world.app() + let pairing = Task { await PairModel(app: app).pair(world.pairingURL) } + await world.run(500) + await pairing.value + #expect(app.site?.session.phase == .streaming) + app.pauseCloud() + let opened = world.cloud.dialled.count + app.setVisible(true) + app.networkOnline() + await app.refresh() + await world.run(500) + #expect(app.cloudPaused) + #expect(world.cloud.dialled.count == opened) + let again = world.app() + again.launch() + await world.run(500) + #expect(again.cloudPaused) + #expect(world.cloud.dialled.count == opened) + #expect(again.vault.isEnrolled) + } + + @Test func pausingOneHomeDoesNotPauseAnotherPairedHome() async throws { + let world=World();let app=world.app() + let pairing=Task { await PairModel(app:app).pair(world.pairingURL) } + await world.run(500);await pairing.value + let original=try #require(app.sites.current()) + var other=original;other.siteId="other-paired-home";other.label="Other" + app.sites.put(other) + app.pauseCloud() + app.open(other.siteId) + #expect(!app.cloudPaused) + #expect(app.sites.get(original.siteId)?.cloudPaused==true) + #expect(app.sites.get(other.siteId)?.cloudPaused != true) + } + @Test func pairStreamCloseAndReopenFromCache() async throws { let world = World() let app = world.app() diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudConsentTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudConsentTests.swift index e7c830de..77bc3c22 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudConsentTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudConsentTests.swift @@ -2,170 +2,163 @@ import Foundation import Testing @testable import FTWKit -/// The first-start consent and the owner's grant at the box. @MainActor @Suite struct CloudConsentTests { func defaults() throws -> UserDefaults { try #require(UserDefaults(suiteName: "ftw-consent-\(UUID().uuidString)")) } - - /// A home whose box answers hello and the cloud routes as Core does. + static let currentPolicy = #"{"policy":"ftw-project-stats-2026-10-v2","cloud_policy":"ftw-cloud-2026-10-v2","cloud_includes_statistics":true,"available":false}"# + static let granted = #"{"state":"on","policy":"ftw-cloud-2026-10-v2","cloudPolicy":"ftw-cloud-2026-10-v2","cloudTermsAccepted":true,"statisticsPolicy":"ftw-project-stats-2026-10-v2","statisticsActive":true,"connected":true}"# func home(role: String = Contract.roleOwner, caps: [String] = [Contract.capCloudLink, Contract.capAPIPassthrough], cloud: Bool = false) async throws -> CloudSite { let w = try CloudSite() w.open() w.site.connect(w.relay) w.relay.api = { method, path, _ in switch (method, path) { - case ("POST", "/api/cloud/grant"): return (200, #"{"state":"on","purposes":["serve"],"connected":false}"#) - case ("POST", "/api/cloud/stats"): return (200, #"{"state":"on","purposes":["serve","stats"]}"#) - case ("DELETE", "/api/cloud/grant"): return (200, #"{"state":"revoking","purposes":["serve"]}"#) - case ("GET", "/api/cloud"): return (200, #"{"state":"off","purposes":[],"purgedAtMs":1791821000000}"#) + case ("GET", "/api/cloud/stats-policy"): return (200, Self.currentPolicy) + case ("POST", "/api/cloud/grant"): return (200, Self.granted) + case ("DELETE", "/api/cloud"): return (200, #"{"state":"revoking","link":"ending"}"#) + case ("GET", "/api/cloud"): return (200, #"{"state":"off","purgedAtMs":1791821000000}"#) default: return (404, #"{"code":"E_NOT_FOUND"}"#) } } w.relay.open(w.scheduler.nowMs) w.relay.helloOk(uptimeMs: w.uptime, cloud: cloud ? w.identity : nil, role: role, caps: caps) - // The screens see a burst one display frame later. await w.run(20) return w } - - @Test func askedOnceOnARealHome() throws { + @Test func oldAnswersAndCheckboxesDoNotAcceptTheNewCloudTerms() async throws { let d = try defaults() - let consent = CloudConsent(defaults: d) - #expect(!consent.shouldAsk(nil)) - let demo = SiteModel(siteId: nil, build: "t", ua: "t", scheduler: ManualScheduler(), files: nil, isDemo: true) - #expect(!consent.shouldAsk(demo)) - let w = try CloudSite() - w.open() - #expect(consent.shouldAsk(w.site)) - #expect(!consent.contributeStats) + d.set("ftw-cloud-2026-10", forKey: CloudConsent.Keys.answered) + d.set(true, forKey: "FTWCloudStats") + let c = CloudConsent(defaults: d) + let w = try await home() + #expect(!c.answered) + #expect(c.shouldAsk(w.site)) + _ = c.shouldAsk(w.site) + #expect(w.relay.calls.isEmpty) } - - @Test func anOwnerTurnsItOnAtTheBoxWithEveryCloudScope() async throws { + @Test func acceptingCloudUsesOneBundledGrantAndNoSeparateStatisticsRequest() async throws { let w = try await home() let d = try defaults() - let consent = CloudConsent(defaults: d) - #expect(CloudConsent.audience(w.site) == .owner) - await consent.accept(w.site) - #expect(consent.answered) - #expect(!consent.shouldAsk(w.site)) - #expect(CloudConsent(defaults: d).answered) - let grant = try #require(w.relay.calls.first) - #expect(grant.method == "POST" && grant.path == "/api/cloud/grant") - let body = try JSON(parsing: Bytes(grant.body.utf8)) - #expect(body["scopes"]?.array?.compactMap(\.string) == Contract.cloudScopes) + let c = CloudConsent(defaults: d) + #expect(await c.accept(w.site)) + #expect(c.answered && c.box?.stats == true && c.box?.termsAccepted == true) + let posts = w.relay.calls.filter { $0.method == "POST" } + #expect(posts.map(\.path) == ["/api/cloud/grant"]) + let body = try JSON(parsing: Bytes(posts[0].body.utf8)) #expect(body["policy"]?.string == CloudConsent.policy) - // No statistics unless ticked. - #expect(w.relay.calls.count == 1) - #expect(consent.box?.on == true) + #expect(body["scopes"]?.array?.compactMap(\.string) == Contract.cloudScopes) + #expect(d.string(forKey: CloudConsent.Keys.asked(w.site.siteId!)) == CloudConsent.policy) + #expect(!c.statisticsReady) // approval gate off; only the choice is saved } - - @Test func statisticsGoOnlyWhenTicked() async throws { + @Test func aLegacyCloudHomeMustAcceptEvenWhenItWasAlreadyOn() async throws { + let w = try await home(cloud: true) + let d = try defaults() + d.set(true, forKey: CloudConsent.Keys.asked(w.site.siteId!)) + let c = CloudConsent(defaults: d) + #expect(await c.accept(w.site)) + #expect(w.relay.calls.contains { $0.path == "/api/cloud/grant" }) + } + @Test func unknownOrOlderPolicyIsNotAccepted() async throws { + for p in [#"{"policy":"ftw-project-stats-2026-10-v1","available":true}"#, #"{"policy":"future-policy","cloud_policy":"future","cloud_includes_statistics":true}"#] { + let w = try await home() + w.relay.api = { _, _, _ in (200, p) } + let c = CloudConsent(defaults: try defaults()) + #expect(await c.accept(w.site) == false) + #expect(!c.answered) + #expect(w.relay.calls.allSatisfy { $0.method == "GET" }) + } + } + @Test func aFailedGrantDoesNotSaveAnAcceptedAnswer() async throws { let w = try await home() - let consent = CloudConsent(defaults: try defaults()) - consent.contributeStats = true - await consent.accept(w.site) - #expect(w.relay.calls.map(\.path) == ["/api/cloud/grant", "/api/cloud/stats"]) - #expect(consent.box?.stats == true) + w.relay.api = { _, path, _ in path == "/api/cloud/stats-policy" ? (200, Self.currentPolicy) : (503, #"{"code":"E_UNAVAILABLE"}"#) } + let d = try defaults() + let c = CloudConsent(defaults: d) + #expect(await c.accept(w.site) == false) + #expect(!c.answered && c.box?.stats != true) + #expect(d.string(forKey: CloudConsent.Keys.asked(w.site.siteId!)) == nil) } - - /// The grant is asked for once per home. After that the box's word - /// stands: turned off on the box or in About, it stays off. - @Test func theGrantIsAskedForOncePerHome() async throws { + @Test func anAcceptedHomeIsNotGrantedAgainOnEveryFrame() async throws { let w = try await home() + let c = CloudConsent(defaults: try defaults()) + await c.accept(w.site) + _ = c.shouldAsk(w.site) + #expect(w.relay.calls.filter { $0.method == "POST" }.count == 1) + } + @Test func turningCloudOffDoesNotTurnItBackOnAtRestart() async throws { let d = try defaults() - let consent = CloudConsent(defaults: d) - await consent.accept(w.site) - await consent.ensure(w.site) - await CloudConsent(defaults: d).ensure(w.site) - #expect(w.relay.calls.count == 1) + let w = try await home() + let c = CloudConsent(defaults: d) + await c.accept(w.site) + #expect(await c.turnOff(w.site)) + #expect(w.relay.calls.last?.path == "/api/cloud") + let later = try await home() + _ = CloudConsent(defaults: d).shouldAsk(later.site) + #expect(later.relay.calls.isEmpty) } - - @Test func aViewerIsToldToAskTheOwner() async throws { + @Test func acceptingAnotherHomeDoesNotSilentlyEnableThisOne() async throws { + let d = try defaults() + d.set(CloudConsent.policy,forKey:CloudConsent.Keys.answered) + d.set(CloudConsent.policy,forKey:CloudConsent.Keys.asked("another-home")) + let w = try await home() + let c = CloudConsent(defaults:d) + #expect(c.shouldAsk(w.site)) + #expect(w.relay.calls.isEmpty) + } + @Test func aViewerCannotGrantTheHomeScope() async throws { let w = try await home(role: Contract.roleViewer) - let consent = CloudConsent(defaults: try defaults()) - #expect(CloudConsent.audience(w.site) == .viewer) - await consent.accept(w.site) - await consent.refresh(w.site) + let c = CloudConsent(defaults: try defaults()) + #expect(await c.accept(w.site)) + #expect(c.answered) #expect(w.relay.calls.isEmpty) - #expect(consent.answered) } - - /// A box that cannot run the uplink yet gets the grant once it can. - @Test func aBoxWithoutTheUplinkIsAskedLater() async throws { + @Test func anOldBoxWaitsForAnUpdate() async throws { let w = try await home(caps: [Contract.capAPIPassthrough]) - let d = try defaults() - let consent = CloudConsent(defaults: d) - #expect(CloudConsent.audience(w.site) == .ownerLater) - await consent.accept(w.site) - #expect(w.relay.calls.isEmpty) - w.relay.helloOk(uptimeMs: w.uptime, cloud: nil, caps: [Contract.capCloudLink, Contract.capAPIPassthrough]) - await w.run(20) - await consent.ensure(w.site) - #expect(w.relay.calls.map(\.path) == ["/api/cloud/grant"]) + let c = CloudConsent(defaults: try defaults()) + #expect(await c.accept(w.site) == false) + #expect(!c.answered && w.relay.calls.isEmpty) } - - @Test func alreadyOnIsLeftAlone() async throws { - let w = try await home(cloud: true) - let consent = CloudConsent(defaults: try defaults()) - await consent.accept(w.site) - #expect(w.relay.calls.isEmpty) + @Test func oldStatisticsStateIsNeverShownAsTheNewScope() throws { + let old = CloudConsent.BoxState(try JSON(parsing: Bytes(#"{"state":"on","purposes":["serve","stats"]}"#.utf8))) + #expect(!old.stats && !old.termsAccepted) } - - /// Found in the simulator: consent given while FTW Cloud was already - /// on, then turned off in About. The next start must not turn it on - /// again. - @Test func offInAboutStaysOffAfterARestart() async throws { + @Test func theNewTermsCanBeReviewedAgain() throws { let d = try defaults() - let w = try await home(cloud: true) - let consent = CloudConsent(defaults: d) - await consent.accept(w.site) - await consent.turnOff(w.site) - // The next start: the box says FTW Cloud is off now. - let later = try await home(cloud: false) - await CloudConsent(defaults: d).ensure(later.site) - #expect(later.relay.calls.isEmpty) + d.set(CloudConsent.policy, forKey: CloudConsent.Keys.answered) + let c = CloudConsent(defaults: d) + #expect(c.answered) + c.review() + #expect(!c.answered) } - - /// Turned off on the box itself, not in the app: the app still leaves - /// it off, because it already asked once. - @Test func offOnTheBoxStaysOff() async throws { - let d = try defaults() - let w = try await home(cloud: true) - await CloudConsent(defaults: d).accept(w.site) - let later = try await home(cloud: false) - await CloudConsent(defaults: d).ensure(later.site) - #expect(later.relay.calls.isEmpty) + @Test func readingTermsCreatesNoReconnectIntentAndNoGrant() async throws { + let w=try await home();let c=CloudConsent(defaults:try defaults()) + c.review() + await c.finishPendingAcceptance(w.site) + #expect(w.relay.calls.isEmpty) } - - /// About: off asks the box to erase the copy; the receipt is the time on - /// the service's confirmation. - @Test func offAndTheReceipt() async throws { - let w = try await home(cloud: true) - let consent = CloudConsent(defaults: try defaults()) - #expect(await consent.turnOff(w.site)) - #expect(consent.box?.erasing == true) - await consent.refresh(w.site) - #expect(consent.box?.on == false) - #expect(consent.box?.erasedAtMs == 1_791_821_000_000) + @Test func onlyExplicitPerHomeReconnectAcceptanceCanFinishAGrant() async throws { + let w=try await home();let c=CloudConsent(defaults:try defaults()) + c.prepareReconnect("another-home") + await c.finishPendingAcceptance(w.site) + #expect(w.relay.calls.isEmpty) + c.prepareReconnect(w.site.siteId!) + #expect(w.relay.calls.isEmpty) + await c.finishPendingAcceptance(w.site) + await c.finishPendingAcceptance(w.site) + #expect(w.relay.calls.filter{$0.method=="POST"}.count==1) + #expect(c.answered) } - - @Test func theScopesAreTheRegistrys() throws { - let yaml = try RepoFiles.read("protocol/registry.yaml") - #expect(yaml.contains("cloud_scopes: [\(Contract.cloudScopes.joined(separator: ", "))]")) - #expect(yaml.contains("cloud_purposes: [\(Contract.cloudPurposes.joined(separator: ", "))]")) - #expect(Contract.cloudPurposes.contains(Contract.cloudPurposeStats)) + @Test func decliningAgainCancelsThePendingReconnectGrant() async throws { + let w=try await home();let c=CloudConsent(defaults:try defaults()) + c.prepareReconnect(w.site.siteId!) + c.cancelReconnect(w.site.siteId!) + await c.finishPendingAcceptance(w.site) + #expect(w.relay.calls.isEmpty) } - - /// Every sentence of the screen has Swedish. @Test func theWordsAreInBothLanguages() { - let keys = ["The FTW app uses FTW Cloud", "Contribute to statistics", "Ask the owner of this home to turn on FTW Cloud.", "Continue", - "Copy erased %@", "FTW Cloud is on for this home."] - for k in keys { #expect(CloudWords.sv[k] != nil, "\(k)") } - AppWords.$language.withValue(.sv) { - #expect(CloudWords.text("Continue") == "Fortsätt") - #expect(CloudWords.text("Contribute to statistics") == "Bidra till statistik") + for key in [CloudConsent.included, CloudConsent.statisticsDetails, CloudConsent.statisticsPrivacy, CloudConsent.statisticsWaiting, "Accept Cloud terms", "Use the box locally", "Review Cloud terms"] { + #expect(CloudWords.sv[key] != nil, "Missing translation: \(key)") } } }