twonly-app/ios/Shared/TwonlyWidgetShared.swift
2026-09-05 01:59:17 +02:00

295 lines
11 KiB
Swift

import AppIntents
import Foundation
import OSLog
import WidgetKit
// Compiled into BOTH the app and the widget extension.
//
// The extension owns the widget's appearance, but only the app can ask
// WidgetKit which widgets are actually on the home screen
// (`getCurrentConfigurations`), and reading their configuration requires the
// very same intent and entity types the extension declares. Everything both
// sides need therefore lives here rather than inside the extension.
//
// The app deploys further back than the widget extension does, so the
// AppIntents declarations carry an explicit availability gate.
let runtimeAppGroup = "group.eu.twonly.runtime"
let widgetKind = "TwonlyWidget"
/// How long a widget may go without rebuilding its timeline before it is taken
/// to have been removed from the home screen. Timelines cover four hours, so a
/// widget that is still placed refreshes far inside this.
let staleWidgetSeconds: Double = 48 * 60 * 60
/// A widget extension is launched by WidgetKit, off any debugger, at moments
/// nobody is watching, and a failure just renders as an empty square. Every
/// step that can silently produce "no image" logs here instead.
///
/// log stream --predicate 'subsystem == "eu.twonly.widget"' --level debug
let widgetLog = Logger(subsystem: "eu.twonly.widget", category: "timeline")
struct Manifest: Decodable {
let groups: [ManifestGroup]
let images: [ManifestImage]
}
struct ManifestGroup: Decodable {
let id: Int64
let name: String
let emoji: String?
}
struct ManifestImage: Decodable {
let mediaId: String
let path: String
let sender: String
/// The contact groups this image is the current one for. The app publishes
/// one image per group, so a widget shows the newest image whose groups meet
/// its own selection and never has a second one to fall back on.
let groupIds: [Int64]
let receivedAt: Int64
enum CodingKeys: String, CodingKey {
case path, sender
case mediaId = "media_id"
case groupIds = "group_ids"
case receivedAt = "received_at"
}
}
enum WidgetStorage {
static var runtimeDirectory: URL? {
FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: runtimeAppGroup)?
.appendingPathComponent("runtime", isDirectory: true)
}
/// Every way reading the manifest can come up empty. The widget renders the
/// reason, because none of these are distinguishable from the home screen
/// otherwise: an unprovisioned App Group, a manifest the app never wrote and
/// a manifest with nothing in it all look like one blank widget.
enum ManifestFault: Error, Equatable {
case appGroupUnavailable
case missing
case unreadable(String)
var summary: String {
switch self {
case .appGroupUnavailable: return "No shared storage"
case .missing: return "Open twonly once"
case .unreadable(let detail): return "Unreadable data (\(detail))"
}
}
}
static func manifest() -> Result<Manifest, ManifestFault> {
guard let url = runtimeDirectory?.appendingPathComponent("widget/manifest.json") else {
widgetLog.error("App Group \(runtimeAppGroup, privacy: .public) is not reachable")
return .failure(.appGroupUnavailable)
}
guard let data = try? Data(contentsOf: url) else {
let exists = FileManager.default.fileExists(atPath: url.path)
widgetLog.error(
"manifest unreadable at \(url.path, privacy: .public) (exists: \(exists, privacy: .public))")
return .failure(exists ? .unreadable("locked") : .missing)
}
do {
let manifest = try JSONDecoder().decode(Manifest.self, from: data)
widgetLog.debug(
"""
manifest \(data.count, privacy: .public) bytes, \
\(manifest.groups.count, privacy: .public) groups, \
\(manifest.images.count, privacy: .public) images
""")
return .success(manifest)
} catch {
widgetLog.error("manifest decode failed: \(error, privacy: .public)")
return .failure(.unreadable("\(error)"))
}
}
static func images() -> Result<[ManifestImage], ManifestFault> {
manifest().map(\.images)
}
static func selectionKey(_ ids: [Int64]) -> String {
ids.sorted().map(String.init).joined(separator: "-")
}
/// Records this widget's contact groups for Rust to import.
///
/// Each placed widget builds its own timeline, so this only ever knows about
/// one of them and has to merge into what the others wrote replacing the
/// list would make every widget erase its neighbours. Nothing tells an
/// extension that a widget was removed either, so each entry carries the time
/// it was last rebuilt and readers drop the ones that stopped reporting.
/// Replaces every iOS entry with the widgets WidgetKit says are placed.
///
/// The extension can only ever add itself, and is never told that its widget
/// was removed, so a removal is invisible from that side. The app can ask
/// outright, which makes this the only exact answer call it before reading
/// the configuration back.
static func storedWidgets() -> [[String: Any]] {
guard let directory = runtimeDirectory?.appendingPathComponent("widget", isDirectory: true),
let data = try? Data(contentsOf: directory.appendingPathComponent("native-config.json")),
let decoded = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let stored = decoded["widgets"] as? [[String: Any]]
else { return [] }
return stored
}
/// Selections that have rebuilt a timeline at or after `since`.
///
/// This is the only evidence available that a widget is really on a home
/// screen. WidgetKit keeps records of widgets it has known about and
/// `getCurrentConfigurations` hands them all back, so its answer alone cannot
/// tell a placed widget from one the system has simply not forgotten. Only a
/// widget that is actually being displayed is asked for a timeline, and
/// `persistSelection` called from nowhere else stamps the moment it was.
static func recentlyRebuiltIds(since: Int) -> Set<String> {
var ids = Set<String>()
for entry in storedWidgets() {
guard let id = entry["id"] as? String, let seen = entry["last_seen"] as? Int
else { continue }
if seen >= since { ids.insert(id) }
}
return ids
}
/// Rewrites the placement file from the widgets the app has established are
/// really placed. Each entry keeps the timestamp its widget wrote: stamping
/// `now` here would forge the very evidence the next reconcile depends on.
static func replaceIosSelections(_ selections: [[Int64]]) {
guard let directory = runtimeDirectory?.appendingPathComponent("widget", isDirectory: true)
else { return }
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
var lastSeen: [String: Int] = [:]
for entry in storedWidgets() {
if let id = entry["id"] as? String, let seen = entry["last_seen"] as? Int {
lastSeen[id] = seen
}
}
// Two widgets configured with the same contact groups are interchangeable
// here: the id is the selection, and Rust only reads the union anyway.
var seen = Set<String>()
var widgets: [[String: Any]] = []
for ids in selections {
let identifier = "ios:\(selectionKey(ids))"
guard seen.insert(identifier).inserted else { continue }
widgets.append([
"id": identifier,
"platform": "ios",
"group_ids": ids,
"last_seen": lastSeen[identifier] ?? 0,
])
}
write(widgets: widgets, to: directory)
}
/// Overwrites the placement file.
///
/// `Data.write(.atomic)` already writes to a neighbouring temporary file and
/// exchanges it, so this does not hand-roll that. The previous version did,
/// and swallowed a failed exchange: the destination still existed, so its
/// fallback never ran and the stale file survived a write that looked like it
/// had succeeded.
private static func write(widgets: [[String: Any]], to directory: URL) {
let destination = directory.appendingPathComponent("native-config.json")
do {
let data = try JSONSerialization.data(withJSONObject: ["widgets": widgets])
try data.write(
to: destination,
options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]
)
widgetLog.debug(
"wrote \(widgets.count, privacy: .public) widget(s) to \(destination.path, privacy: .public)"
)
} catch {
widgetLog.error("could not write the placement file: \(error, privacy: .public)")
}
// A crash between the two steps of the old implementation could have left
// this behind, and it would otherwise sit in the App Group forever.
try? FileManager.default.removeItem(
at: directory.appendingPathComponent("native-config.json.tmp"))
}
static func persistSelection(_ ids: [Int64]) {
guard let directory = runtimeDirectory?.appendingPathComponent("widget", isDirectory: true)
else { return }
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try? (directory as NSURL).setResourceValue(
URLFileProtection.completeUntilFirstUserAuthentication,
forKey: .fileProtectionKey
)
let identifier = "ios:\(selectionKey(ids))"
let widget: [String: Any] = [
"id": identifier,
"platform": "ios",
"group_ids": ids,
"last_seen": Int(Date().timeIntervalSince1970),
]
let destination = directory.appendingPathComponent("native-config.json")
var widgets: [[String: Any]] = []
if let existing = try? Data(contentsOf: destination),
let decoded = try? JSONSerialization.jsonObject(with: existing) as? [String: Any],
let stored = decoded["widgets"] as? [[String: Any]] {
// Android's entries are authoritative and carry no timestamp, so they are
// always kept; only this platform's own stale rows are dropped.
widgets = stored.filter { entry in
guard entry["id"] as? String != identifier else { return false }
guard let seen = entry["last_seen"] as? Int else { return true }
return Date().timeIntervalSince1970 - Double(seen) < staleWidgetSeconds
}
}
widgets.append(widget)
write(widgets: widgets, to: directory)
}
}
@available(iOS 17.0, *)
struct ContactGroupEntity: AppEntity {
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Contact group")
static let defaultQuery = ContactGroupQuery()
let id: String
let name: String
let emoji: String?
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(emoji.map { "\($0) " } ?? "")\(name)")
}
}
@available(iOS 17.0, *)
struct ContactGroupQuery: EntityQuery {
func entities(for identifiers: [String]) async throws -> [ContactGroupEntity] {
let wanted = Set(identifiers)
return allEntities().filter { wanted.contains($0.id) }
}
private func allEntities() -> [ContactGroupEntity] {
let groups = (try? WidgetStorage.manifest().get())?.groups ?? []
return groups.map {
ContactGroupEntity(id: String($0.id), name: $0.name, emoji: $0.emoji)
}
}
func suggestedEntities() async throws -> [ContactGroupEntity] { allEntities() }
}
@available(iOS 17.0, *)
struct TwonlyWidgetIntent: WidgetConfigurationIntent {
static let title: LocalizedStringResource = "twonly contact groups"
static let description = IntentDescription("Choose who may share images with this widget.")
@Parameter(title: "Contact groups")
var groups: [ContactGroupEntity]?
init() { groups = nil }
}