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
8 changes: 8 additions & 0 deletions appleApp/FTW-Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@
<dict>
<key>FTWAppGroup</key>
<string>$(FTW_APP_GROUP)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>energy.ftw.app.prices</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
Expand Down
55 changes: 54 additions & 1 deletion appleApp/FTW/FTWApp.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import BackgroundTasks
import FTWDemo
import FTWKit
import os
import SwiftUI
import WidgetKit

Expand Down Expand Up @@ -50,9 +52,60 @@ struct FTWApp: App {
// switcher: slow the stream, keep the session. Only the
// background closes it.
app.setVisible(phase == .active)
if phase == .background { app.suspend() }
PricesInBackground.inBackground = phase == .background
if phase == .background {
app.suspend()
Task { await PricesInBackground.schedule(app) }
}
}
}
.backgroundTask(.appRefresh(PricesInBackground.id)) { [app] in
await PricesInBackground.run(app)
}
}
}

/// Tomorrow's prices for the widgets without opening the app. iOS wakes the
/// app shortly after 13:05 if it allows it at all; the app opens a session,
/// asks for prices, leaves them in the App Group and closes again. No silent
/// push and no server: when iOS does not wake the app, the widgets wait for
/// the next time it is opened.
@MainActor
enum PricesInBackground {
/// Listed under BGTaskSchedulerPermittedIdentifiers in FTW-Info.plist.
static let id = "energy.ftw.app.prices"
static let log = Logger(subsystem: "energy.ftw.app", category: "widgets")
/// Set from the scene phase. A cold launch for this task has no scene,
/// so it starts in the background.
static var inBackground = true

static func schedule(_ app: AppModel) async {
guard let site = app.site, site.siteId != nil, !app.isDemo else { return }
let pricesEndMs = await site.sharedPricesEndMs()
let nowMs = Date.now.timeIntervalSince1970 * 1000
let at = Date(timeIntervalSince1970: PriceRefresh.next(afterMs: nowMs, pricesEndMs: pricesEndMs) / 1000)
let request = BGAppRefreshTaskRequest(identifier: id)
request.earliestBeginDate = at
do {
try BGTaskScheduler.shared.submit(request)
log.info("background prices asked for after \(at.formatted(date: .abbreviated, time: .shortened), privacy: .public)")
} catch {
// The simulator, Background App Refresh turned off, or Low Power
// Mode. The widgets then wait for the app to be opened.
log.notice("background prices not scheduled: \(error.localizedDescription, privacy: .public)")
}
}

static func run(_ app: AppModel) async {
guard let site = app.site, site.siteId != nil, !app.isDemo else { return }
log.info("background prices: woken")
// A new carrier if the last one was closed, or a fresh stream.
await app.refresh()
let prices = await site.refreshSharedPrices()
log.info("background prices: \(prices == nil ? "nothing written" : "\(prices!.slots.count) slots written", privacy: .public)")
// Someone may have opened the app meanwhile; their session stays.
if inBackground { app.suspend() }
await schedule(app)
}
}

Expand Down
133 changes: 122 additions & 11 deletions appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import Foundation
import FTWShared
import Observation
import os

private let log = Logger(subsystem: "energy.ftw.app", category: "widgets")

/// The bridge between the session and the screens.
///
Expand Down Expand Up @@ -40,6 +43,12 @@ public final class SiteModel {
/// What widgets read. Nil for the demo and in tests.
@ObservationIgnored private let shared: AppGroupFiles?
@ObservationIgnored private var lastCars: (entry: CarEntry, atMs: Double)?
/// The prices last left for the widgets, so the same prices again are
/// neither written nor reloaded.
@ObservationIgnored private var lastPrices: PriceEntry?
/// The latest write to `shared`. Each waits for the one before, so they
/// land in the order they were asked for.
@ObservationIgnored private var writing: Task<Void, Never>?
/// Called after every write to `shared`, so the app can wake its widgets.
@ObservationIgnored public var sharedWritten: (@MainActor () -> Void)?
@ObservationIgnored private var attemptStartedAtMs: Double
Expand Down Expand Up @@ -367,17 +376,24 @@ public final class SiteModel {
/// each of those.
static let carRewriteMs: Double = 5 * 60_000

/// Leave today's and tomorrow's prices for the widgets.
func sharePrices(_ prices: Prices, fromMs: Double) {
guard let siteId, let shared else { return }
/// Leave today's and tomorrow's prices for the widgets. The same prices
/// again are skipped: the widgets' timeline already holds them, and a
/// reload would only redraw it. Returns the write, for a caller that
/// must not end before it lands.
@discardableResult
func sharePrices(_ prices: Prices, fromMs: Double) -> Task<Void, Never>? {
guard let siteId, let shared else { return nil }
let entry = PriceEntry(fromMs: fromMs, prices: prices)
guard entry != lastPrices else { return nil }
lastPrices = entry
let record = CacheRecord(
siteId: siteId,
carrier: session.carrier,
srcState: prices.stale ? .stale : .live,
readAtMs: scheduler.nowMs,
value: PriceEntry(fromMs: fromMs, prices: prices)
value: entry
)
write(record, to: shared)
return write(record, to: shared)
}

/// Leave the chargers for the widgets, as the box just described them.
Expand All @@ -402,14 +418,109 @@ public final class SiteModel {
write(CacheRecord(siteId: siteId, carrier: session.carrier, srcState: answering ? .live : .stale, readAtMs: now, value: entry), to: shared)
}

private func write<Value: CacheValue>(_ record: CacheRecord<Value>, to shared: AppGroupFiles) {
guard !destroyed else { return }
/// Encoding and the file write run off the main actor. A sign-out
/// asked for meanwhile wins: the write is dropped rather than putting
/// the home back (`AppGroupFiles.clearCount`).
@discardableResult
private func write<Value: CacheValue>(_ record: CacheRecord<Value>, to shared: AppGroupFiles) -> Task<Void, Never>? {
guard !destroyed else { return nil }
let asked = shared.clearCount
let previous = writing
let task = Task { [weak self] in
await previous?.value
do {
guard try await shared.write(record, asked: asked) else { return }
log.info("wrote \(Value.name, privacy: .public) for widgets, source \(record.srcState.rawValue, privacy: .public)")
guard let self, !self.destroyed else { return }
self.sharedWritten?()
} catch {
// A widget showing yesterday's record with its date is
// better than no app; the next read tries again.
log.error("could not write \(Value.name, privacy: .public) for widgets: \(error.localizedDescription, privacy: .public)")
if Value.self == PriceEntry.self { self?.lastPrices = nil }
}
}
writing = task
return task
}

/// Where the prices the widgets hold run out. Nil when they hold none.
/// Read from what this process wrote, or off the main actor from disk
/// after a cold launch.
public func sharedPricesEndMs() async -> Double? {
if let lastPrices { return Self.end(lastPrices) }
guard let siteId, let shared else { return nil }
return await Self.readEnd(shared, siteId: siteId)
}

@concurrent
private nonisolated static func readEnd(_ shared: AppGroupFiles, siteId: String) async -> Double? {
shared.read(PriceEntry.self, siteId: siteId).flatMap { end($0.value) }
}

private nonisolated static func end(_ entry: PriceEntry) -> Double? {
entry.prices.slots.map { $0.startMs + $0.durationMs }.max()
}

/// The background refresh: wait for the stream, ask for today's and
/// tomorrow's prices as the price card does, and leave them for the
/// widgets. Returns what was written, or nil when nothing was: no
/// stream within `waitMs`, a box without prices, or a failed ask.
///
/// The caller builds the carrier and closes it after.
@discardableResult
public func refreshSharedPrices(calendar: Calendar = .current, waitMs: Double = 20_000) async -> Prices? {
guard siteId != nil, shared != nil, !destroyed else { return nil }
guard await streaming(within: waitMs) else {
log.notice("background prices: no stream within \(Int(waitMs / 1000), privacy: .public) s")
return nil
}
guard session.caps.contains(Contract.capPriceSpot) else {
log.notice("background prices: this box offers no prices")
return nil
}
let from = calendar.startOfDay(for: Date(timeIntervalSince1970: nowMs / 1000)).timeIntervalSince1970 * 1000
do {
try shared.write(record)
sharedWritten?()
let wire = try await prices(fromMs: from, toMs: from + PriceModel.horizonMs)
sharePrices(wire, fromMs: from)
// iOS may suspend the app once the task returns: land the write
// first, or the one already on its way with these prices.
await writing?.value
return wire
} catch {
// A widget showing yesterday's record with its date is better
// than no app; the next read tries again.
log.error("background prices: \(error.localizedDescription, privacy: .public)")
return nil
}
}

/// True once the session streams, false if it has not within `ms`.
private func streaming(within ms: Double) async -> Bool {
if session.phase == .streaming { return true }
let wait = StreamWait()
let ok = await withCheckedContinuation { cont in
wait.continuation = cont
wait.token = observe { [weak self, wait] in
if self?.session.phase == .streaming { wait.settle(true) }
}
wait.timer = scheduler.after(ms) { [wait] in wait.settle(false) }
}
if let token = wait.token { unobserve(token) }
return ok
}
}

/// One wait for the stream, settled once. The timer settles it even if the
/// model is gone, so the background task always ends.
@MainActor
private final class StreamWait {
var continuation: CheckedContinuation<Bool, Never>?
var token: Int?
var timer: Cancellable?

func settle(_ ok: Bool) {
guard let cont = continuation else { return }
continuation = nil
timer?.cancel()
cont.resume(returning: ok)
}
}
40 changes: 36 additions & 4 deletions appleApp/FTWKit/Sources/FTWShared/Cache.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import Synchronization

// What the app leaves on disk for its widgets, and later for a notification
// extension: one home's prices and its car, each with where it came from
Expand Down Expand Up @@ -64,6 +65,11 @@ public struct CacheRead<Value: CacheValue>: Sendable {
public let record: CacheRecord<Value>
public let origin: CacheOrigin

public init(record: CacheRecord<Value>, origin: CacheOrigin) {
self.record = record
self.origin = origin
}

public var value: Value { record.value }
/// Always `.cache`. A file on this phone is not a connection to the box.
public var carrier: CarrierKind { .cache }
Expand Down Expand Up @@ -153,14 +159,40 @@ public struct AppGroupFiles: CacheSource {

/// Only the app writes. Atomic, so a widget never reads half a file.
public func write<Value: CacheValue>(_ record: CacheRecord<Value>) throws {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let data = try JSONEncoder().encode(record)
try data.write(to: url(Value.name, record.siteId), options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
try Self.clears.withLock { _ in try store(record) }
}

/// Writes off the caller's thread. `asked` is `clearCount` when the write
/// was asked for: a clear since then means the home was signed out, so
/// the write is dropped rather than putting the home back. Returns
/// whether it wrote.
@concurrent
public nonisolated func write<Value: CacheValue>(_ record: CacheRecord<Value>, asked: Int) async throws -> Bool {
try Self.clears.withLock { counts in
guard counts[directory, default: 0] == asked else { return false }
try store(record)
return true
}
}

/// Everything, for signing out. A widget then has nothing to draw.
public func removeAll() {
try? FileManager.default.removeItem(at: directory)
Self.clears.withLock { counts in
counts[directory, default: 0] += 1
try? FileManager.default.removeItem(at: directory)
}
}

/// How many times this process has cleared this cache. Writes and
/// clears take the same lock, so they land in the order they were asked
/// for.
public var clearCount: Int { Self.clears.withLock { $0[directory, default: 0] } }
private static let clears = Mutex<[URL: Int]>([:])

private func store<Value: CacheValue>(_ record: CacheRecord<Value>) throws {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let data = try JSONEncoder().encode(record)
try data.write(to: url(Value.name, record.siteId), options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
}
}

Expand Down
Loading
Loading