Archived
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b28c5a0502 | ||
|
|
ad7a405f32 | ||
|
|
c8a36c161f | ||
|
|
75a6a5bd79 |
@@ -509,6 +509,7 @@
|
|||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = "Calendarr-Info.plist";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
||||||
INFOPLIST_KEY_CFBundleName = Calendarr;
|
INFOPLIST_KEY_CFBundleName = Calendarr;
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
@@ -558,6 +559,7 @@
|
|||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = "Calendarr-Info.plist";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
||||||
INFOPLIST_KEY_CFBundleName = Calendarr;
|
INFOPLIST_KEY_CFBundleName = Calendarr;
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
|
|||||||
@@ -4,10 +4,18 @@ import SwiftUI
|
|||||||
struct CalendarrApp: App {
|
struct CalendarrApp: App {
|
||||||
@State private var appState = AppState()
|
@State private var appState = AppState()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Registration has to happen before the app finishes launching, which
|
||||||
|
// is why it is here and not in a .task modifier.
|
||||||
|
BackgroundRefresh.register()
|
||||||
|
WatchSyncService.shared.activate()
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
RootView()
|
RootView()
|
||||||
.environment(appState)
|
.environment(appState)
|
||||||
|
.task { BackgroundRefresh.schedule() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,6 +115,9 @@ class AppState {
|
|||||||
// say "sign in to Calendarr" rather than "open Calendarr once".
|
// say "sign in to Calendarr" rather than "open Calendarr once".
|
||||||
WidgetStore.clear()
|
WidgetStore.clear()
|
||||||
publishSession()
|
publishSession()
|
||||||
|
// The watch keeps its own copy of the snapshot, so clearing ours is not
|
||||||
|
// enough — it has to be told, or it keeps rendering this user's events.
|
||||||
|
WatchSyncService.shared.pushSignedOut()
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetServer() {
|
func resetServer() {
|
||||||
|
|||||||
@@ -599,7 +599,8 @@ class CalendarStore {
|
|||||||
colorHex: ev.calendarColor)
|
colorHex: ev.calendarColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WidgetStore.writeCalendars(Array(calendarMap.values).sorted { $0.name < $1.name })
|
let calendars = Array(calendarMap.values).sorted { $0.name < $1.name }
|
||||||
|
WidgetStore.writeCalendars(calendars)
|
||||||
|
|
||||||
let visible = allCachedEvents
|
let visible = allCachedEvents
|
||||||
.filter { ev in
|
.filter { ev in
|
||||||
@@ -620,10 +621,14 @@ class CalendarStore {
|
|||||||
location: ev.location,
|
location: ev.location,
|
||||||
calendarKey: Self.calendarKey(source: ev.source, calendarId: ev.calendarId))
|
calendarKey: Self.calendarKey(source: ev.source, calendarId: ev.calendarId))
|
||||||
}
|
}
|
||||||
WidgetStore.write(WidgetStore.makeSnapshot(events: Array(visible),
|
let snapshot = WidgetStore.makeSnapshot(events: Array(visible),
|
||||||
coverageStart: from,
|
coverageStart: from,
|
||||||
coverageEnd: to))
|
coverageEnd: to)
|
||||||
|
WidgetStore.write(snapshot)
|
||||||
WidgetTimelineNotifier.reload()
|
WidgetTimelineNotifier.reload()
|
||||||
|
// The watch has its own container and cannot read the file we just
|
||||||
|
// wrote, so it is handed the data explicitly.
|
||||||
|
WatchSyncService.shared.push(snapshot: snapshot, calendars: calendars)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Writable calendars
|
// MARK: – Writable calendars
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import BackgroundTasks
|
||||||
|
import Foundation
|
||||||
|
import CalendarrCore
|
||||||
|
|
||||||
|
/// Keeps the shared snapshot fresh while the app is not in the foreground.
|
||||||
|
///
|
||||||
|
/// Without this, `publishWidgetSnapshot()` only ever runs from a user action,
|
||||||
|
/// so an event created on the web or on Android does not reach the widgets — or
|
||||||
|
/// the watch — until the app is opened again. iOS decides when this actually
|
||||||
|
/// runs, based on how the user uses the app: it improves freshness, it does not
|
||||||
|
/// guarantee it.
|
||||||
|
@MainActor
|
||||||
|
enum BackgroundRefresh {
|
||||||
|
static let taskIdentifier = "com.scarriffleservices.calendarr.ios.refresh"
|
||||||
|
|
||||||
|
/// Minimum gap between runs we ask for. iOS treats this as the earliest
|
||||||
|
/// acceptable time, not a promise.
|
||||||
|
private static let interval: TimeInterval = 30 * 60
|
||||||
|
|
||||||
|
/// Call once at launch, before the app finishes starting up.
|
||||||
|
static func register() {
|
||||||
|
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier,
|
||||||
|
using: nil) { task in
|
||||||
|
guard let refresh = task as? BGAppRefreshTask else {
|
||||||
|
task.setTaskCompleted(success: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task { @MainActor in await handle(refresh) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask for the next run. Safe to call repeatedly; a duplicate submission
|
||||||
|
/// replaces the pending request rather than queueing a second one.
|
||||||
|
static func schedule() {
|
||||||
|
let request = BGAppRefreshTaskRequest(identifier: taskIdentifier)
|
||||||
|
request.earliestBeginDate = Date(timeIntervalSinceNow: interval)
|
||||||
|
do { try BGTaskScheduler.shared.submit(request) }
|
||||||
|
catch {
|
||||||
|
// Submission fails on a simulator and when the user has disabled
|
||||||
|
// background refresh. Neither is worth interrupting anyone over.
|
||||||
|
print("[BackgroundRefresh] submit failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handle(_ task: BGAppRefreshTask) async {
|
||||||
|
// Reschedule first: if the work below throws or is killed, the chain
|
||||||
|
// must not end here.
|
||||||
|
schedule()
|
||||||
|
|
||||||
|
let work = Task { @MainActor in await refreshSnapshot() }
|
||||||
|
task.expirationHandler = { work.cancel() }
|
||||||
|
|
||||||
|
let succeeded = await work.value
|
||||||
|
task.setTaskCompleted(success: succeeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the snapshot window and republish. Returns false when there was
|
||||||
|
/// nothing to do or the fetch failed.
|
||||||
|
private static func refreshSnapshot() async -> Bool {
|
||||||
|
let state = AppState()
|
||||||
|
guard state.isConfigured, state.isLoggedIn else { return false }
|
||||||
|
|
||||||
|
// An SSO session can lapse while the app is closed; renewing first
|
||||||
|
// avoids spending the whole background window on a 401.
|
||||||
|
await OIDCSessionRefresher.refreshIfNeeded(state)
|
||||||
|
|
||||||
|
let api = CalendarrAPI(baseURL: state.serverURL, token: state.authToken)
|
||||||
|
let store = CalendarStore()
|
||||||
|
|
||||||
|
let calendar = Calendar(identifier: .gregorian)
|
||||||
|
let dayStart = calendar.startOfDay(for: Date())
|
||||||
|
guard let from = calendar.date(byAdding: .day, value: -SnapshotCoverage.daysBehind, to: dayStart),
|
||||||
|
let to = calendar.date(byAdding: .day, value: SnapshotCoverage.daysAhead, to: dayStart)
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
// force: a freshly created store has an empty in-memory cache, so
|
||||||
|
// without this the "already cached" short-circuit would skip the fetch.
|
||||||
|
await store.loadEvents(api: api, start: from, end: to, force: true)
|
||||||
|
|
||||||
|
return store.lastError == nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@ final class OIDCAuthenticator {
|
|||||||
/// the app target generates its Info.plist, which has no way to declare
|
/// the app target generates its Info.plist, which has no way to declare
|
||||||
/// `CFBundleURLTypes`. Rather than hang forever waiting for a callback that
|
/// `CFBundleURLTypes`. Rather than hang forever waiting for a callback that
|
||||||
/// cannot arrive, refuse up front.
|
/// cannot arrive, refuse up front.
|
||||||
var guidedAccessBlocksLogin: Bool { UIAccessibilityIsGuidedAccessEnabled() }
|
var guidedAccessBlocksLogin: Bool { UIAccessibility.isGuidedAccessEnabled }
|
||||||
|
|
||||||
func discover(issuer: String) async throws -> OIDServiceConfiguration {
|
func discover(issuer: String) async throws -> OIDServiceConfiguration {
|
||||||
guard let url = URL(string: issuer) else { throw OIDCError(slug: "oidc_bad_issuer") }
|
guard let url = URL(string: issuer) else { throw OIDCError(slug: "oidc_bad_issuer") }
|
||||||
@@ -174,7 +174,7 @@ final class OIDCAuthenticator {
|
|||||||
// The original request travels inside the stored state, so the nonce
|
// The original request travels inside the stored state, so the nonce
|
||||||
// is still available. A refreshed token often drops the claim, which
|
// is still available. A refreshed token often drops the claim, which
|
||||||
// the server tolerates — but when it keeps it, it has to match.
|
// the server tolerates — but when it keeps it, it has to match.
|
||||||
let nonce = state.lastAuthorizationResponse?.request.nonce
|
let nonce = state.lastAuthorizationResponse.request.nonce
|
||||||
return (idToken, tokens.1, nonce)
|
return (idToken, tokens.1, nonce)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import Foundation
|
||||||
|
import WatchConnectivity
|
||||||
|
import CalendarrCore
|
||||||
|
|
||||||
|
/// Feeds the watch. App Groups are per-device, so the watch cannot read the
|
||||||
|
/// file this app just wrote — it has to be handed the data.
|
||||||
|
///
|
||||||
|
/// Three delivery paths, because each one alone leaves a gap:
|
||||||
|
///
|
||||||
|
/// 1. `updateApplicationContext` carries only the latest state and has no queue
|
||||||
|
/// to overflow. It is delivered when the watch app next launches or wakes.
|
||||||
|
/// 2. `transferCurrentComplicationUserInfo` is the only path that wakes the
|
||||||
|
/// watch app while it is not running. Its daily budget is small, so it is
|
||||||
|
/// spent only when the *next* appointment changed.
|
||||||
|
/// 3. A reply to `sendMessage` covers the watch app asking while it is open.
|
||||||
|
@MainActor
|
||||||
|
final class WatchSyncService: NSObject {
|
||||||
|
static let shared = WatchSyncService()
|
||||||
|
|
||||||
|
private let sessions = SharedSessionStore()
|
||||||
|
private static let lastIdentityKey = "watchLastPushedNextEvent"
|
||||||
|
private static let pushTimesKey = "watchComplicationPushTimes"
|
||||||
|
/// The system budget is roughly 50 a day; this keeps a burst of edits from
|
||||||
|
/// spending it in an hour.
|
||||||
|
private static let maxPushesPerHour = 4
|
||||||
|
|
||||||
|
private var session: WCSession? {
|
||||||
|
WCSession.isSupported() ? WCSession.default : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call once at launch.
|
||||||
|
func activate() {
|
||||||
|
guard let session else { return }
|
||||||
|
session.delegate = self
|
||||||
|
session.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish the current calendar to the watch.
|
||||||
|
func push(snapshot: CalendarrSnapshot, calendars: [SnapshotCalendar]) {
|
||||||
|
guard let session, session.activationState == .activated else { return }
|
||||||
|
|
||||||
|
let trimmed = snapshot.trimmedForWatch()
|
||||||
|
let payload = WatchTransportPayload(snapshot: trimmed,
|
||||||
|
calendars: calendars,
|
||||||
|
session: sessions.read())
|
||||||
|
guard let dict = try? payload.encoded() else { return }
|
||||||
|
|
||||||
|
try? session.updateApplicationContext(dict)
|
||||||
|
|
||||||
|
guard session.isComplicationEnabled else { return }
|
||||||
|
let current = WatchPushPolicy.nextEventIdentity(in: trimmed, at: Date())
|
||||||
|
guard WatchPushPolicy.shouldPushComplicationUpdate(previous: loadLastIdentity(),
|
||||||
|
current: current),
|
||||||
|
consumePushBudget()
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
session.transferCurrentComplicationUserInfo(dict)
|
||||||
|
storeLastIdentity(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell the watch the user signed out, so it stops showing their calendar.
|
||||||
|
/// The watch keeps its own copy of the snapshot, so clearing ours is not
|
||||||
|
/// enough.
|
||||||
|
func pushSignedOut() {
|
||||||
|
guard let session, session.activationState == .activated else { return }
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
let empty = CalendarrSnapshot(
|
||||||
|
writtenAt: now, coverageStart: now, coverageEnd: now,
|
||||||
|
isLoggedIn: false, writerVersion: "signout", events: [],
|
||||||
|
theme: SnapshotTheme(today: "#4285f4", text: "#FFFFFF", background: "#000000",
|
||||||
|
line: "#3A3A52", primary: "#4285f4", accent: "#ea4335"),
|
||||||
|
language: "system")
|
||||||
|
let payload = WatchTransportPayload(snapshot: empty, calendars: [], session: sessions.read())
|
||||||
|
guard let dict = try? payload.encoded() else { return }
|
||||||
|
|
||||||
|
try? session.updateApplicationContext(dict)
|
||||||
|
if session.isComplicationEnabled, consumePushBudget() {
|
||||||
|
session.transferCurrentComplicationUserInfo(dict)
|
||||||
|
}
|
||||||
|
storeLastIdentity(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The payload for a watch that asked directly. Built from the file we
|
||||||
|
/// already wrote, so an on-demand pull cannot disagree with a push.
|
||||||
|
fileprivate func currentPayloadDictionary() -> [String: Any]? {
|
||||||
|
let store = SnapshotStore()
|
||||||
|
guard case .ok(let snapshot) = store.read() else { return nil }
|
||||||
|
let payload = WatchTransportPayload(snapshot: snapshot.trimmedForWatch(),
|
||||||
|
calendars: store.readCalendars(),
|
||||||
|
session: sessions.read())
|
||||||
|
return try? payload.encoded()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Push budget
|
||||||
|
|
||||||
|
private func consumePushBudget() -> Bool {
|
||||||
|
let cutoff = Date().addingTimeInterval(-3600)
|
||||||
|
var times = (UserDefaults.standard.array(forKey: Self.pushTimesKey) as? [Double] ?? [])
|
||||||
|
.map { Date(timeIntervalSince1970: $0) }
|
||||||
|
.filter { $0 > cutoff }
|
||||||
|
|
||||||
|
guard times.count < Self.maxPushesPerHour else {
|
||||||
|
UserDefaults.standard.set(times.map(\.timeIntervalSince1970), forKey: Self.pushTimesKey)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
times.append(Date())
|
||||||
|
UserDefaults.standard.set(times.map(\.timeIntervalSince1970), forKey: Self.pushTimesKey)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Last pushed identity
|
||||||
|
|
||||||
|
private func loadLastIdentity() -> NextEventIdentity? {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: Self.lastIdentityKey) else { return nil }
|
||||||
|
let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601
|
||||||
|
return try? decoder.decode(NextEventIdentity.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func storeLastIdentity(_ identity: NextEventIdentity?) {
|
||||||
|
guard let identity else {
|
||||||
|
UserDefaults.standard.removeObject(forKey: Self.lastIdentityKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let encoder = JSONEncoder(); encoder.dateEncodingStrategy = .iso8601
|
||||||
|
UserDefaults.standard.set(try? encoder.encode(identity), forKey: Self.lastIdentityKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WatchSyncService: WCSessionDelegate {
|
||||||
|
nonisolated func session(_ session: WCSession,
|
||||||
|
activationDidCompleteWith state: WCSessionActivationState,
|
||||||
|
error: Error?) {
|
||||||
|
if let error {
|
||||||
|
print("[WatchSync] activation failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The watch asking for the current state, which is what covers "it is open
|
||||||
|
/// in front of me and looks stale".
|
||||||
|
nonisolated func session(_ session: WCSession,
|
||||||
|
didReceiveMessage message: [String: Any],
|
||||||
|
replyHandler: @escaping ([String: Any]) -> Void) {
|
||||||
|
Task { @MainActor in
|
||||||
|
replyHandler(self.currentPayloadDictionary() ?? [:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required on iOS: the session is torn down and re-activated when the user
|
||||||
|
// switches to a different paired watch.
|
||||||
|
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||||
|
|
||||||
|
nonisolated func sessionDidDeactivate(_ session: WCSession) {
|
||||||
|
WCSession.default.activate()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.scarriffleservices.calendarr.ios.refresh</string>
|
||||||
|
</array>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>fetch</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import WidgetKit
|
import WidgetKit
|
||||||
import AppIntents
|
import AppIntents
|
||||||
|
import CalendarrCore
|
||||||
|
|
||||||
struct CalendarrEntry: TimelineEntry {
|
struct CalendarrEntry: TimelineEntry {
|
||||||
let date: Date
|
let date: Date
|
||||||
@@ -37,22 +38,9 @@ struct CalendarrTimelineProvider: AppIntentTimelineProvider {
|
|||||||
// MARK: – Filtering
|
// MARK: – Filtering
|
||||||
|
|
||||||
private func filtered(_ snapshot: WidgetSnapshot?, by config: CalendarSelectionIntent) -> WidgetSnapshot? {
|
private func filtered(_ snapshot: WidgetSnapshot?, by config: CalendarSelectionIntent) -> WidgetSnapshot? {
|
||||||
guard let snapshot else { return nil }
|
// The rule lives in CalendarrCore so the watch provider cannot drift
|
||||||
let ids = (config.selectedCalendars ?? []).map { $0.id }
|
// from this one. Nothing selected means "show all calendars".
|
||||||
guard !ids.isEmpty else { return snapshot } // nothing selected = show all
|
snapshot?.filtered(toCalendarKeys: Set((config.selectedCalendars ?? []).map(\.id)))
|
||||||
let keep = Set(ids)
|
|
||||||
// Filtering narrows the events but not the window: the snapshot still
|
|
||||||
// speaks for the same range, it just has fewer calendars in it.
|
|
||||||
return WidgetSnapshot(
|
|
||||||
writtenAt: snapshot.writtenAt,
|
|
||||||
coverageStart: snapshot.coverageStart,
|
|
||||||
coverageEnd: snapshot.coverageEnd,
|
|
||||||
isLoggedIn: snapshot.isLoggedIn,
|
|
||||||
writerVersion: snapshot.writerVersion,
|
|
||||||
events: snapshot.events.filter { keep.contains($0.calendarKey) },
|
|
||||||
theme: snapshot.theme,
|
|
||||||
language: snapshot.language
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user