chore(repo): iOS-App als ios/ ins Hauptrepo uebernehmen
Calendarr-IOS wird mit vollstaendiger Historie als Unterverzeichnis ios/ eingebunden. Damit liegen Server, Web, Android und iOS in einem gemeinsamen Repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import Foundation
|
||||
import Contacts
|
||||
import UIKit
|
||||
|
||||
/// One stored birthday row as returned by GET /api/local/calendars/{id}/birthdays.
|
||||
/// Contact-sourced rows carry an `externalUid`; manually added ones have `nil`.
|
||||
struct BirthdayEntry: Codable, Identifiable {
|
||||
let uid: String
|
||||
let externalUid: String?
|
||||
let title: String
|
||||
let month: Int?
|
||||
let day: Int?
|
||||
let birthYear: Int?
|
||||
var id: String { uid }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case uid, title, month, day
|
||||
case externalUid = "external_uid"
|
||||
case birthYear = "birth_year"
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads birthdays from the system Contacts and mirrors them into the user's
|
||||
/// single birthday calendar on the backend, so they show on every client.
|
||||
///
|
||||
/// The sync is a **mirror**, scoped to THIS device: it reconciles rows whose
|
||||
/// `external_uid` starts with `contact:<deviceId>:` (adds new, updates changed,
|
||||
/// deletes removed) and never touches other devices' rows or manually added
|
||||
/// birthdays. Age suffix, cake icon and "notify N days before" are done
|
||||
/// server-side; this only uploads name + date + birth year, and reports the
|
||||
/// device so the web can list "birthdays come from these devices".
|
||||
enum BirthdaysImporter {
|
||||
|
||||
// MARK: – Persisted state
|
||||
|
||||
enum Key {
|
||||
static let enabled = "birthdaysSyncEnabled" // Bool
|
||||
static let deviceId = "birthdaysDeviceId" // stable per-install UUID
|
||||
}
|
||||
|
||||
static var isEnabled: Bool { UserDefaults.standard.bool(forKey: Key.enabled) }
|
||||
static func setEnabled(_ on: Bool) { UserDefaults.standard.set(on, forKey: Key.enabled) }
|
||||
|
||||
static var deviceId: String {
|
||||
if let id = UserDefaults.standard.string(forKey: Key.deviceId) { return id }
|
||||
let id = UUID().uuidString
|
||||
UserDefaults.standard.set(id, forKey: Key.deviceId)
|
||||
return id
|
||||
}
|
||||
|
||||
@MainActor static var deviceName: String {
|
||||
let n = UIDevice.current.name
|
||||
return n.isEmpty ? "iPhone" : n
|
||||
}
|
||||
|
||||
private static var appLang: String {
|
||||
UserDefaults.standard.string(forKey: "appLanguage") ?? "system"
|
||||
}
|
||||
|
||||
// MARK: – Contacts access
|
||||
|
||||
static var isAuthorized: Bool {
|
||||
let s = CNContactStore.authorizationStatus(for: .contacts)
|
||||
if s == .authorized { return true }
|
||||
if #available(iOS 18.0, *), s == .limited { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func requestAccess() async -> Bool {
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
if status == .authorized { return true }
|
||||
if #available(iOS 18.0, *), status == .limited { return true }
|
||||
guard status == .notDetermined else { return false }
|
||||
return await withCheckedContinuation { cont in
|
||||
CNContactStore().requestAccess(for: .contacts) { granted, _ in
|
||||
cont.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Reading contact birthdays
|
||||
|
||||
struct ContactBirthday {
|
||||
let contactId: String
|
||||
let name: String
|
||||
let month: Int
|
||||
let day: Int
|
||||
let year: Int?
|
||||
}
|
||||
|
||||
static func readContactBirthdays() throws -> [ContactBirthday] {
|
||||
let store = CNContactStore()
|
||||
let keys: [CNKeyDescriptor] = [
|
||||
CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
|
||||
CNContactGivenNameKey as CNKeyDescriptor,
|
||||
CNContactFamilyNameKey as CNKeyDescriptor,
|
||||
CNContactOrganizationNameKey as CNKeyDescriptor,
|
||||
CNContactBirthdayKey as CNKeyDescriptor,
|
||||
]
|
||||
let req = CNContactFetchRequest(keysToFetch: keys)
|
||||
var out: [ContactBirthday] = []
|
||||
try store.enumerateContacts(with: req) { contact, _ in
|
||||
guard let bday = contact.birthday, let m = bday.month, let d = bday.day else { return }
|
||||
var name = CNContactFormatter.string(from: contact, style: .fullName) ?? ""
|
||||
if name.isEmpty {
|
||||
name = [contact.givenName, contact.familyName]
|
||||
.filter { !$0.isEmpty }.joined(separator: " ")
|
||||
}
|
||||
if name.isEmpty { name = contact.organizationName }
|
||||
guard !name.isEmpty else { return }
|
||||
let year = bday.year.flatMap { $0 > 0 ? $0 : nil }
|
||||
out.append(ContactBirthday(
|
||||
contactId: contact.identifier, name: name, month: m, day: d, year: year
|
||||
))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: – The single birthday calendar
|
||||
|
||||
/// The user's birthday calendar, creating the single "Geburtstage" calendar
|
||||
/// if none exists yet.
|
||||
static func ensureBirthdayCalendar(api: CalendarrAPI) async -> LocalCalendar? {
|
||||
let cals = (try? await api.getLocalCalendars()) ?? []
|
||||
if let existing = cals.first(where: { $0.isBirthday && $0.owned }) { return existing }
|
||||
let name = L10n.t("birthday.calendar_name", appLang)
|
||||
return try? await api.addLocalCalendar(name: name, color: "#E0407F", isBirthday: true)
|
||||
}
|
||||
|
||||
static func birthdayCalendar(api: CalendarrAPI) async -> LocalCalendar? {
|
||||
let cals = (try? await api.getLocalCalendars()) ?? []
|
||||
return cals.first(where: { $0.isBirthday && $0.owned })
|
||||
}
|
||||
|
||||
// MARK: – Sync
|
||||
|
||||
/// Mirror the address book into the birthday calendar (creating it if
|
||||
/// needed). No-op unless enabled and Contacts access is granted.
|
||||
static func sync(api: CalendarrAPI) async {
|
||||
guard isEnabled else { return }
|
||||
guard await requestAccess() else { return }
|
||||
guard let contacts = try? readContactBirthdays() else { return }
|
||||
// Only fill an EXISTING birthday calendar — never auto-create it. The
|
||||
// calendar is created explicitly; deleting it means sync has nowhere to
|
||||
// go (and must not resurrect it).
|
||||
guard let cal = await birthdayCalendar(api: api) else { return }
|
||||
guard let existing = try? await api.getBirthdayEntries(calendarId: cal.id) else { return }
|
||||
|
||||
// Only reconcile THIS device's contact rows; leave other devices'
|
||||
// rows and manual entries untouched.
|
||||
let prefix = "contact:\(deviceId):"
|
||||
var byExt: [String: BirthdayEntry] = [:]
|
||||
for e in existing {
|
||||
if let ext = e.externalUid, ext.hasPrefix(prefix) { byExt[ext] = e }
|
||||
}
|
||||
|
||||
var seen = Set<String>()
|
||||
for c in contacts {
|
||||
let ext = prefix + c.contactId
|
||||
seen.insert(ext)
|
||||
let (start, end) = allDayRange(month: c.month, day: c.day, year: c.year)
|
||||
if let match = byExt[ext] {
|
||||
let changed = match.title != c.name || match.month != c.month
|
||||
|| match.day != c.day || match.birthYear != c.year
|
||||
if changed {
|
||||
try? await api.updateLocalEvent(
|
||||
uid: match.uid, title: c.name, start: start, end: end,
|
||||
isAllDay: true, location: "", description: "", color: nil,
|
||||
rrule: "FREQ=YEARLY", externalUid: ext, birthYear: c.year ?? -1
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_ = try? await api.createLocalEvent(
|
||||
calendarId: cal.id, title: c.name, start: start, end: end,
|
||||
isAllDay: true, location: "", description: "", color: nil,
|
||||
rrule: "FREQ=YEARLY", externalUid: ext, birthYear: c.year
|
||||
)
|
||||
}
|
||||
}
|
||||
for (ext, entry) in byExt where !seen.contains(ext) {
|
||||
try? await api.deleteLocalEvent(uid: entry.uid)
|
||||
}
|
||||
|
||||
// Report this device so the web can list where birthdays come from.
|
||||
let name = await deviceName
|
||||
try? await api.reportBirthdaySync(deviceId: deviceId, deviceName: name, count: contacts.count)
|
||||
}
|
||||
|
||||
/// All-day [start, end) for a birthday. Anchor year = birth year when known,
|
||||
/// else 1970 so FREQ=YEARLY expands across any queried range. Built at local
|
||||
/// noon so day-only formatting can't roll to an adjacent day.
|
||||
private static func allDayRange(month: Int, day: Int, year: Int?) -> (Date, Date) {
|
||||
var comp = DateComponents()
|
||||
comp.year = year ?? 1970
|
||||
comp.month = month
|
||||
comp.day = day
|
||||
comp.hour = 12
|
||||
let cal = Calendar.current
|
||||
let start = cal.date(from: comp) ?? Date()
|
||||
let end = cal.date(byAdding: .day, value: 1, to: start) ?? start
|
||||
return (start, end)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
import Foundation
|
||||
|
||||
enum APIError: LocalizedError {
|
||||
case invalidURL
|
||||
case unauthorized
|
||||
case twoFactorRequired
|
||||
case serverError(String)
|
||||
case decodingError
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: return "Ungültige Server-URL"
|
||||
case .unauthorized: return "Benutzername oder Passwort falsch"
|
||||
case .twoFactorRequired: return "2FA-Code erforderlich"
|
||||
case .serverError(let msg): return msg
|
||||
case .decodingError: return "Antwort konnte nicht verarbeitet werden"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CalendarrAPI {
|
||||
let baseURL: String
|
||||
let token: String
|
||||
|
||||
init(baseURL: String, token: String) {
|
||||
self.baseURL = baseURL
|
||||
self.token = token
|
||||
}
|
||||
|
||||
private func request(_ path: String, method: String = "GET", body: [String: Any]? = nil) async throws -> Data {
|
||||
guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL }
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = method
|
||||
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
if let body {
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||
}
|
||||
let (data, response) = try await URLSession.shared.data(for: req)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if status == 401 { throw APIError.unauthorized }
|
||||
if status >= 400 {
|
||||
let msg = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String ?? "Fehler \(status)"
|
||||
throw APIError.serverError(msg)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
static func login(baseURL: String, username: String, password: String, totpCode: String? = nil, rememberMe: Bool = false) async throws -> (token: String, username: String, isAdmin: Bool) {
|
||||
guard let url = URL(string: baseURL + "/api/auth/login") else { throw APIError.invalidURL }
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
var body: [String: Any] = ["username": username, "password": password, "remember_me": rememberMe]
|
||||
if let code = totpCode { body["totp_code"] = code }
|
||||
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||
let (data, response) = try await URLSession.shared.data(for: req)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if status == 401 {
|
||||
let detail = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String ?? ""
|
||||
if detail == "2fa_required" { throw APIError.twoFactorRequired }
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
if status >= 400 {
|
||||
let msg = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String ?? "Fehler"
|
||||
throw APIError.serverError(msg)
|
||||
}
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let token = json["access_token"] as? String,
|
||||
let user = json["user"] as? [String: Any],
|
||||
let uname = user["username"] as? String else {
|
||||
throw APIError.decodingError
|
||||
}
|
||||
let admin = user["is_admin"] as? Bool ?? false
|
||||
// Persist id + display name for creator/owner comparisons and display.
|
||||
UserDefaults.standard.set(user["id"] as? Int ?? 0, forKey: "userId")
|
||||
UserDefaults.standard.set(user["display_name"] as? String ?? uname, forKey: "displayName")
|
||||
return (token, uname, admin)
|
||||
}
|
||||
|
||||
// MARK: - Single sign-on (OpenID Connect)
|
||||
|
||||
/// Providers configured on the server. Empty when SSO is off or the server
|
||||
/// predates it — the password form is unaffected either way.
|
||||
static func oidcProviders(baseURL: String) async throws -> [OIDCProvider] {
|
||||
guard let url = URL(string: baseURL + "/api/auth/oidc/providers") else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
let (data, response) = try await URLSession.shared.data(from: url)
|
||||
guard (response as? HTTPURLResponse)?.statusCode == 200,
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
json["enabled"] as? Bool == true,
|
||||
let list = json["providers"] as? [[String: Any]] else { return [] }
|
||||
|
||||
return list.compactMap { entry in
|
||||
// Without a mobile client id the app cannot start a flow.
|
||||
guard let key = entry["key"] as? String,
|
||||
let clientID = entry["mobile_client_id"] as? String, !clientID.isEmpty,
|
||||
let issuer = entry["issuer"] as? String else { return nil }
|
||||
return OIDCProvider(
|
||||
key: key,
|
||||
name: (entry["name"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? key,
|
||||
issuer: issuer,
|
||||
// mobile_scopes is what the server wants the apps to request
|
||||
// (it knows whether the provider grants offline_access).
|
||||
scopes: (entry["mobile_scopes"] as? String)
|
||||
?? (entry["scopes"] as? String)
|
||||
?? "openid profile email",
|
||||
mobileClientID: clientID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trade a provider ID token for a Calendarr token.
|
||||
///
|
||||
/// The app is a public client: this request carries no secret, only the ID
|
||||
/// token and the nonce AppAuth bound into the flow.
|
||||
static func exchangeOIDC(baseURL: String,
|
||||
provider: String,
|
||||
clientID: String,
|
||||
idToken: String,
|
||||
accessToken: String?,
|
||||
nonce: String?) async throws -> (token: String, username: String, isAdmin: Bool) {
|
||||
guard let url = URL(string: baseURL + "/api/auth/oidc/exchange") else { throw APIError.invalidURL }
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
var body: [String: Any] = ["provider": provider, "client_id": clientID, "id_token": idToken]
|
||||
if let accessToken { body["access_token"] = accessToken }
|
||||
if let nonce { body["nonce"] = nonce }
|
||||
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: req)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if status >= 400 {
|
||||
// The server answers with a stable slug the login screen translates.
|
||||
let detail = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String
|
||||
throw OIDCError(slug: detail?.isEmpty == false ? detail! : "oidc_failed")
|
||||
}
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let token = json["access_token"] as? String,
|
||||
let user = json["user"] as? [String: Any],
|
||||
let uname = user["username"] as? String else {
|
||||
throw APIError.decodingError
|
||||
}
|
||||
let admin = user["is_admin"] as? Bool ?? false
|
||||
// Same side effect as login(): creator/owner comparisons elsewhere read
|
||||
// these back, so an SSO user must not be left with a zero userId.
|
||||
UserDefaults.standard.set(user["id"] as? Int ?? 0, forKey: "userId")
|
||||
UserDefaults.standard.set(user["display_name"] as? String ?? uname, forKey: "displayName")
|
||||
return (token, uname, admin)
|
||||
}
|
||||
|
||||
static func checkSetupRequired(baseURL: String) async throws -> Bool {
|
||||
guard let url = URL(string: baseURL + "/api/auth/setup-required") else { throw APIError.invalidURL }
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
return json?["required"] as? Bool ?? false
|
||||
}
|
||||
|
||||
func getSettings() async throws -> AppSettings {
|
||||
let data = try await request("/api/settings/")
|
||||
guard let settings = try? JSONDecoder().decode(AppSettings.self, from: data) else { throw APIError.decodingError }
|
||||
return settings
|
||||
}
|
||||
|
||||
func updateSettings(_ settings: AppSettings) async throws {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||
guard let body = try? encoder.encode(settings),
|
||||
let dict = try? JSONSerialization.jsonObject(with: body) as? [String: Any] else { return }
|
||||
_ = try await request("/api/settings/", method: "PUT", body: dict)
|
||||
}
|
||||
|
||||
func getProfile() async throws -> UserProfile {
|
||||
let data = try await request("/api/auth/me")
|
||||
guard let profile = try? JSONDecoder().decode(UserProfile.self, from: data) else { throw APIError.decodingError }
|
||||
return profile
|
||||
}
|
||||
|
||||
func updateEmail(_ email: String) async throws {
|
||||
_ = try await request("/api/profile/", method: "PATCH", body: ["email": email])
|
||||
}
|
||||
|
||||
func changePassword(current: String, new: String) async throws {
|
||||
_ = try await request("/api/profile/password", method: "POST", body: ["current_password": current, "new_password": new])
|
||||
}
|
||||
|
||||
func getCalDAVAccounts() async throws -> [CalDAVAccount] {
|
||||
let data = try await request("/api/caldav/accounts")
|
||||
return (try? JSONDecoder().decode([CalDAVAccount].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
func addCalDAVAccount(name: String, url: String, username: String, password: String, color: String) async throws -> CalDAVAccount {
|
||||
let data = try await request("/api/caldav/accounts", method: "POST", body: [
|
||||
"name": name, "url": url, "username": username, "password": password, "color": color
|
||||
])
|
||||
guard let acc = try? JSONDecoder().decode(CalDAVAccount.self, from: data) else { throw APIError.decodingError }
|
||||
return acc
|
||||
}
|
||||
|
||||
func deleteCalDAVAccount(id: Int) async throws {
|
||||
_ = try await request("/api/caldav/accounts/\(id)", method: "DELETE")
|
||||
}
|
||||
|
||||
func getLocalCalendars() async throws -> [LocalCalendar] {
|
||||
let data = try await request("/api/local/calendars")
|
||||
return (try? JSONDecoder().decode([LocalCalendar].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
func addLocalCalendar(name: String, color: String,
|
||||
isBirthday: Bool = false,
|
||||
birthdayNotifyDaysBefore: Int? = nil) async throws -> LocalCalendar {
|
||||
var body: [String: Any] = ["name": name, "color": color]
|
||||
if isBirthday { body["is_birthday"] = true }
|
||||
if let d = birthdayNotifyDaysBefore { body["birthday_notify_days_before"] = d }
|
||||
let data = try await request("/api/local/calendars", method: "POST", body: body)
|
||||
guard let cal = try? JSONDecoder().decode(LocalCalendar.self, from: data) else { throw APIError.decodingError }
|
||||
return cal
|
||||
}
|
||||
|
||||
func deleteLocalCalendar(id: Int) async throws {
|
||||
_ = try await request("/api/local/calendars/\(id)", method: "DELETE")
|
||||
}
|
||||
|
||||
/// Update a local calendar's birthday settings. `is_birthday` marks it as a
|
||||
/// birthday calendar; `notifyDaysBefore` sets the reminder (nil sends the -1
|
||||
/// sentinel to clear it server-side).
|
||||
func updateLocalCalendarBirthday(id: Int, isBirthday: Bool? = nil, notifyDaysBefore: Int? = nil) async throws {
|
||||
var body: [String: Any] = [:]
|
||||
if let b = isBirthday { body["is_birthday"] = b }
|
||||
if let n = notifyDaysBefore { body["birthday_notify_days_before"] = n } // -1 clears server-side
|
||||
guard !body.isEmpty else { return }
|
||||
_ = try await request("/api/local/calendars/\(id)", method: "PUT", body: body)
|
||||
}
|
||||
|
||||
/// Raw (unexpanded) rows of a birthday calendar, used by the Contacts
|
||||
/// importer to reconcile by `external_uid`.
|
||||
func getBirthdayEntries(calendarId: Int) async throws -> [BirthdayEntry] {
|
||||
let data = try await request("/api/local/calendars/\(calendarId)/birthdays")
|
||||
return (try? JSONDecoder().decode([BirthdayEntry].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
/// Report that this device just synced its Contacts birthdays, so the web
|
||||
/// can show "birthdays come from these devices".
|
||||
func reportBirthdaySync(deviceId: String, deviceName: String, count: Int) async throws {
|
||||
_ = try await request("/api/birthdays/sync-report", method: "POST", body: [
|
||||
"device_id": deviceId, "device_name": deviceName, "count": count,
|
||||
])
|
||||
}
|
||||
|
||||
func getICalSubscriptions() async throws -> [ICalSubscription] {
|
||||
let data = try await request("/api/ical/subscriptions")
|
||||
return (try? JSONDecoder().decode([ICalSubscription].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
func addICalSubscription(name: String, url: String, color: String, refreshMinutes: Int) async throws -> ICalSubscription {
|
||||
let data = try await request("/api/ical/subscriptions", method: "POST", body: [
|
||||
"name": name, "url": url, "color": color, "refresh_minutes": refreshMinutes
|
||||
])
|
||||
guard let sub = try? JSONDecoder().decode(ICalSubscription.self, from: data) else { throw APIError.decodingError }
|
||||
return sub
|
||||
}
|
||||
|
||||
func deleteICalSubscription(id: Int) async throws {
|
||||
_ = try await request("/api/ical/subscriptions/\(id)", method: "DELETE")
|
||||
}
|
||||
|
||||
func getGoogleAccounts() async throws -> [GoogleAccount] {
|
||||
let data = try await request("/api/google/accounts")
|
||||
return (try? JSONDecoder().decode([GoogleAccount].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
func deleteGoogleAccount(id: Int) async throws {
|
||||
_ = try await request("/api/google/accounts/\(id)", method: "DELETE")
|
||||
}
|
||||
|
||||
func getHomeAssistantAccounts() async throws -> [HomeAssistantAccount] {
|
||||
let data = try await request("/api/homeassistant/accounts")
|
||||
return (try? JSONDecoder().decode([HomeAssistantAccount].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
func addHomeAssistantAccount(name: String, url: String, token: String) async throws -> HomeAssistantAccount {
|
||||
let data = try await request("/api/homeassistant/accounts", method: "POST", body: [
|
||||
"name": name, "url": url, "token": token, "auth_method": "token"
|
||||
])
|
||||
guard let acc = try? JSONDecoder().decode(HomeAssistantAccount.self, from: data) else { throw APIError.decodingError }
|
||||
return acc
|
||||
}
|
||||
|
||||
func deleteHomeAssistantAccount(id: Int) async throws {
|
||||
_ = try await request("/api/homeassistant/accounts/\(id)", method: "DELETE")
|
||||
}
|
||||
|
||||
func setup2FA() async throws -> (secret: String, qrUrl: String) {
|
||||
let data = try await request("/api/profile/2fa/setup", method: "POST")
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let secret = json["secret"] as? String,
|
||||
let qr = json["qr_url"] as? String else { throw APIError.decodingError }
|
||||
return (secret, qr)
|
||||
}
|
||||
|
||||
func enable2FA(code: String) async throws {
|
||||
_ = try await request("/api/profile/2fa/enable", method: "POST", body: ["code": code])
|
||||
}
|
||||
|
||||
func disable2FA(password: String) async throws {
|
||||
_ = try await request("/api/profile/2fa/disable", method: "POST", body: ["password": password])
|
||||
}
|
||||
|
||||
// MARK: – Events
|
||||
|
||||
/// Fetches events for the personal calendar view. The server also reports
|
||||
/// per-calendar sync failures (e.g. expired credentials) alongside an
|
||||
/// otherwise-successful response, via the `errors` array — surfaced
|
||||
/// separately from a hard fetch failure so the UI can distinguish
|
||||
/// "everything failed" from "fetched fine, but calendar X didn't sync".
|
||||
func fetchEvents(start: Date, end: Date) async throws -> (events: [CalEvent], errors: [SyncError]) {
|
||||
// Use UTC with Z suffix – avoids '+' character which breaks URL query params
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
iso.timeZone = TimeZone(abbreviation: "UTC")
|
||||
let s = iso.string(from: start) // e.g. "2026-05-01T00:00:00Z"
|
||||
let e = iso.string(from: end)
|
||||
let sEnc = s.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? s
|
||||
let eEnc = e.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? e
|
||||
let data = try await request("/api/caldav/events?start=\(sEnc)&end=\(eEnc)")
|
||||
// Server returns {"events": [...], "errors": [...]}
|
||||
guard
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let arr = root["events"] as? [[String: Any]]
|
||||
else {
|
||||
let preview = String(data: data, encoding: .utf8).map { String($0.prefix(200)) } ?? "no data"
|
||||
throw APIError.serverError("Unerwartete Antwort: \(preview)")
|
||||
}
|
||||
let events = arr.compactMap { CalEvent.from(json: $0) }
|
||||
let errors = (root["errors"] as? [[String: Any]])?.compactMap { SyncError.from(json: $0) } ?? []
|
||||
return (events, errors)
|
||||
}
|
||||
|
||||
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
|
||||
isAllDay: Bool, location: String, description: String, color: String?,
|
||||
isPrivate: Bool = false, reminders: [Int]? = nil,
|
||||
rrule: String? = nil, externalUid: String? = nil,
|
||||
birthYear: Int? = nil) async throws -> CalEvent {
|
||||
var body: [String: Any] = [
|
||||
"calendar_id": calendarId,
|
||||
"title": title,
|
||||
"start": formatISO(start, allDay: isAllDay),
|
||||
"end": formatISO(end, allDay: isAllDay),
|
||||
"allDay": isAllDay,
|
||||
"location": location,
|
||||
"description": description,
|
||||
"private": isPrivate
|
||||
]
|
||||
if let c = color, !c.isEmpty { body["color"] = c }
|
||||
if let reminders { body["reminders"] = reminders }
|
||||
if let rrule, !rrule.isEmpty { body["rrule"] = rrule }
|
||||
if let externalUid { body["external_uid"] = externalUid }
|
||||
if let birthYear { body["birth_year"] = birthYear }
|
||||
let data = try await request("/api/local/events", method: "POST", body: body)
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let ev = CalEvent.from(json: json) else { throw APIError.decodingError }
|
||||
return ev
|
||||
}
|
||||
|
||||
func updateLocalEvent(uid: String, title: String, start: Date, end: Date,
|
||||
isAllDay: Bool, location: String, description: String, color: String?,
|
||||
isPrivate: Bool = false, reminders: [Int]? = nil,
|
||||
rrule: String? = nil, externalUid: String? = nil,
|
||||
birthYear: Int? = nil) async throws {
|
||||
var body: [String: Any] = [
|
||||
"title": title,
|
||||
"start": formatISO(start, allDay: isAllDay),
|
||||
"end": formatISO(end, allDay: isAllDay),
|
||||
"allDay": isAllDay,
|
||||
"location": location,
|
||||
"description": description,
|
||||
"private": isPrivate
|
||||
]
|
||||
if let c = color { body["color"] = c }
|
||||
if let reminders { body["reminders"] = reminders }
|
||||
if let rrule { body["rrule"] = rrule }
|
||||
if let externalUid { body["external_uid"] = externalUid }
|
||||
if let birthYear { body["birth_year"] = birthYear }
|
||||
_ = try await request("/api/local/events/\(uid)", method: "PUT", body: body)
|
||||
}
|
||||
|
||||
func deleteLocalEvent(uid: String) async throws {
|
||||
_ = try await request("/api/local/events/\(uid)", method: "DELETE")
|
||||
}
|
||||
|
||||
func createCalDAVEvent(calendarId: Int, title: String, start: Date, end: Date,
|
||||
isAllDay: Bool, location: String, description: String, color: String?) async throws {
|
||||
var body: [String: Any] = [
|
||||
"calendar_id": calendarId,
|
||||
"title": title,
|
||||
"start": formatISO(start, allDay: isAllDay),
|
||||
"end": formatISO(end, allDay: isAllDay),
|
||||
"allDay": isAllDay,
|
||||
"location": location,
|
||||
"description": description
|
||||
]
|
||||
if let c = color, !c.isEmpty { body["color"] = c }
|
||||
_ = try await request("/api/caldav/events", method: "POST", body: body)
|
||||
}
|
||||
|
||||
func updateCalDAVEvent(uid: String, url: String, calendarId: Int?, title: String,
|
||||
start: Date, end: Date, isAllDay: Bool,
|
||||
location: String, description: String, color: String?) async throws {
|
||||
var body: [String: Any] = [
|
||||
"title": title,
|
||||
"start": formatISO(start, allDay: isAllDay),
|
||||
"end": formatISO(end, allDay: isAllDay),
|
||||
"allDay": isAllDay,
|
||||
"location": location,
|
||||
"description": description
|
||||
]
|
||||
if let c = color { body["color"] = c }
|
||||
let encURL = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? url
|
||||
var path = "/api/caldav/events/\(uid)?event_url=\(encURL)"
|
||||
if let cid = calendarId { path += "&calendar_id=\(cid)" }
|
||||
_ = try await request(path, method: "PUT", body: body)
|
||||
}
|
||||
|
||||
func deleteCalDAVEvent(uid: String, url: String, calendarId: Int?) async throws {
|
||||
let encURL = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? url
|
||||
var path = "/api/caldav/events/\(uid)?event_url=\(encURL)"
|
||||
if let cid = calendarId { path += "&calendar_id=\(cid)" }
|
||||
_ = try await request(path, method: "DELETE")
|
||||
}
|
||||
|
||||
// MARK: – Google Calendar events
|
||||
|
||||
func getGoogleCalendars() async throws -> [(accountEmail: String, calId: Int, name: String, color: String)] {
|
||||
let data = try await request("/api/google/accounts")
|
||||
guard let accounts = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
|
||||
var result: [(String, Int, String, String)] = []
|
||||
for acc in accounts {
|
||||
let email = acc["email"] as? String ?? "Google"
|
||||
let cals = acc["calendars"] as? [[String: Any]] ?? []
|
||||
for cal in cals where (cal["enabled"] as? Bool ?? true) {
|
||||
if let id = cal["id"] as? Int, let name = cal["name"] as? String {
|
||||
let color = cal["color"] as? String ?? "#4285f4"
|
||||
result.append((email, id, name, color))
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func createGoogleEvent(calendarDbId: Int, title: String, start: Date, end: Date,
|
||||
isAllDay: Bool, location: String, description: String) async throws {
|
||||
let body: [String: Any] = [
|
||||
"calendar_db_id": calendarDbId,
|
||||
"title": title,
|
||||
"start": formatISO(start, allDay: isAllDay),
|
||||
"end": formatISO(end, allDay: isAllDay),
|
||||
"allDay": isAllDay,
|
||||
"location": location,
|
||||
"description": description
|
||||
]
|
||||
_ = try await request("/api/google/events", method: "POST", body: body)
|
||||
}
|
||||
|
||||
// MARK: – Home Assistant events
|
||||
|
||||
func getHACalendars() async throws -> [(accountName: String, calId: Int, name: String, color: String)] {
|
||||
let data = try await request("/api/homeassistant/accounts")
|
||||
guard let accounts = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
|
||||
var result: [(String, Int, String, String)] = []
|
||||
for acc in accounts {
|
||||
let aName = acc["name"] as? String ?? "Home Assistant"
|
||||
let cals = acc["calendars"] as? [[String: Any]] ?? []
|
||||
for cal in cals where (cal["enabled"] as? Bool ?? true) {
|
||||
if let id = cal["id"] as? Int, let name = cal["name"] as? String {
|
||||
let color = cal["color"] as? String ?? "#46bdc6"
|
||||
result.append((aName, id, name, color))
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func createHAEvent(calendarId: Int, title: String, start: Date, end: Date,
|
||||
isAllDay: Bool, location: String, description: String) async throws {
|
||||
let body: [String: Any] = [
|
||||
"calendar_id": calendarId,
|
||||
"title": title,
|
||||
"start": formatISO(start, allDay: isAllDay),
|
||||
"end": formatISO(end, allDay: isAllDay),
|
||||
"allDay": isAllDay,
|
||||
"location": location,
|
||||
"description": description
|
||||
]
|
||||
_ = try await request("/api/homeassistant/events", method: "POST", body: body)
|
||||
}
|
||||
|
||||
/// Delete a Home Assistant calendar event.
|
||||
/// `calendarId` is the numeric HA-calendar DB id; `uid` is the HA event uid.
|
||||
func deleteHAEvent(calendarId: Int, uid: String) async throws {
|
||||
// uid is a path segment and may contain "/" or other reserved chars.
|
||||
let allowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/"))
|
||||
let encUid = uid.addingPercentEncoding(withAllowedCharacters: allowed) ?? uid
|
||||
_ = try await request("/api/homeassistant/events/\(calendarId)/\(encUid)", method: "DELETE")
|
||||
}
|
||||
|
||||
// MARK: – Calendar visibility (sidebar_hidden)
|
||||
|
||||
/// Toggle a calendar's server-side visibility. Mirrors the web: hiding sets
|
||||
/// `enabled=false, sidebar_hidden=true` (server then omits its events);
|
||||
/// showing sets `enabled=true, sidebar_hidden=false`. Only CalDAV / Google /
|
||||
/// Home Assistant have this flag; `local` / `ical` are a no-op.
|
||||
func setCalendarSidebarHidden(source: String, calendarId: Int, hidden: Bool) async throws {
|
||||
let path: String
|
||||
switch source {
|
||||
case "caldav": path = "/api/caldav/calendars/\(calendarId)"
|
||||
case "google": path = "/api/google/calendars/\(calendarId)"
|
||||
case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)"
|
||||
default: return
|
||||
}
|
||||
_ = try await request(path, method: "PUT",
|
||||
body: ["enabled": !hidden, "sidebar_hidden": hidden])
|
||||
}
|
||||
|
||||
/// Toggle a calendar's server-side `reminders_enabled` flag. Supported for
|
||||
/// all source types (caldav/google/homeassistant/local/ical).
|
||||
func setCalendarRemindersEnabled(source: String, calendarId: Int, enabled: Bool) async throws {
|
||||
let path: String
|
||||
switch source {
|
||||
case "caldav": path = "/api/caldav/calendars/\(calendarId)"
|
||||
case "google": path = "/api/google/calendars/\(calendarId)"
|
||||
case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)"
|
||||
case "local": path = "/api/local/calendars/\(calendarId)"
|
||||
case "ical": path = "/api/ical/subscriptions/\(calendarId)"
|
||||
default: return
|
||||
}
|
||||
_ = try await request(path, method: "PUT", body: ["reminders_enabled": enabled])
|
||||
}
|
||||
|
||||
// MARK: – Calendar colour
|
||||
|
||||
/// Colour-only update: works for owners AND share recipients (recipients get
|
||||
/// their own per-user colour; owners change the calendar's colour) — unlike
|
||||
/// the owner-only PUT /calendars/{id}, and never touches the name.
|
||||
func updateLocalCalendarColor(id: Int, color: String) async throws {
|
||||
_ = try await request("/api/local/calendars/\(id)/color", method: "PUT", body: ["color": color])
|
||||
}
|
||||
|
||||
func updateICalColor(id: Int, color: String) async throws {
|
||||
_ = try await request("/api/ical/subscriptions/\(id)", method: "PUT", body: ["color": color])
|
||||
}
|
||||
|
||||
/// Set a per-calendar colour for server-managed sources (caldav/google/homeassistant).
|
||||
func setCalendarColor(source: String, calendarId: Int, color: String) async throws {
|
||||
let path: String
|
||||
switch source {
|
||||
case "caldav": path = "/api/caldav/calendars/\(calendarId)"
|
||||
case "google": path = "/api/google/calendars/\(calendarId)"
|
||||
case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)"
|
||||
default: return
|
||||
}
|
||||
_ = try await request(path, method: "PUT", body: ["color": color])
|
||||
}
|
||||
|
||||
// MARK: – Profile (display name / login name / email)
|
||||
|
||||
/// Update profile fields. A login-name change returns a fresh token (the old
|
||||
/// one becomes invalid) — the caller must store the returned token.
|
||||
func updateProfile(displayName: String?, username: String?, email: String?,
|
||||
directoryHidden: Bool? = nil) async throws -> String? {
|
||||
var body: [String: Any] = [:]
|
||||
if let d = displayName { body["display_name"] = d }
|
||||
if let u = username { body["username"] = u }
|
||||
if let e = email { body["email"] = e } else { body["email"] = NSNull() }
|
||||
if let h = directoryHidden { body["directory_hidden"] = h }
|
||||
let data = try await request("/api/profile/", method: "PUT", body: body)
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
return json?["access_token"] as? String
|
||||
}
|
||||
|
||||
// MARK: – Targeted settings (avoid overwriting the whole AppSettings)
|
||||
|
||||
func updatePrivateVisibility(_ value: String) async throws {
|
||||
_ = try await request("/api/settings/", method: "PUT", body: ["private_event_visibility": value])
|
||||
}
|
||||
|
||||
func updateGroupVisibleCalendar(_ calendarId: Int?) async throws {
|
||||
_ = try await request("/api/settings/", method: "PUT",
|
||||
body: ["group_visible_calendar_id": calendarId as Any? ?? NSNull()])
|
||||
}
|
||||
|
||||
// MARK: – Sharing
|
||||
|
||||
func getUserDirectory() async throws -> [DirectoryUser] {
|
||||
let data = try await request("/api/users/directory")
|
||||
return (try? JSONDecoder().decode([DirectoryUser].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
func getShares(calendarId: Int) async throws -> [CalendarShare] {
|
||||
let data = try await request("/api/local/calendars/\(calendarId)/shares")
|
||||
return (try? JSONDecoder().decode([CalendarShare].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
func addShare(calendarId: Int, userId: Int, permission: String) async throws {
|
||||
_ = try await request("/api/local/calendars/\(calendarId)/shares", method: "POST",
|
||||
body: ["user_id": userId, "permission": permission])
|
||||
}
|
||||
|
||||
func removeShare(calendarId: Int, userId: Int) async throws {
|
||||
_ = try await request("/api/local/calendars/\(calendarId)/shares/\(userId)", method: "DELETE")
|
||||
}
|
||||
|
||||
// MARK: – Groups
|
||||
|
||||
func getGroups() async throws -> [CalGroup] {
|
||||
let data = try await request("/api/groups/")
|
||||
return (try? JSONDecoder().decode([CalGroup].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
func getGroup(id: Int) async throws -> CalGroup {
|
||||
let data = try await request("/api/groups/\(id)")
|
||||
guard let g = try? JSONDecoder().decode(CalGroup.self, from: data) else { throw APIError.decodingError }
|
||||
return g
|
||||
}
|
||||
|
||||
func createGroup(name: String, memberIds: [Int], icon: String?) async throws -> CalGroup {
|
||||
var body: [String: Any] = ["name": name, "member_ids": memberIds]
|
||||
if let icon { body["icon"] = icon }
|
||||
let data = try await request("/api/groups/", method: "POST", body: body)
|
||||
guard let g = try? JSONDecoder().decode(CalGroup.self, from: data) else { throw APIError.decodingError }
|
||||
return g
|
||||
}
|
||||
|
||||
func updateGroup(id: Int, name: String?, icon: String?) async throws {
|
||||
var body: [String: Any] = [:]
|
||||
if let name { body["name"] = name }
|
||||
if let icon { body["icon"] = icon }
|
||||
_ = try await request("/api/groups/\(id)", method: "PUT", body: body)
|
||||
}
|
||||
|
||||
func deleteGroup(id: Int) async throws {
|
||||
_ = try await request("/api/groups/\(id)", method: "DELETE")
|
||||
}
|
||||
|
||||
func addGroupMember(groupId: Int, userId: Int) async throws {
|
||||
_ = try await request("/api/groups/\(groupId)/members", method: "POST", body: ["user_id": userId])
|
||||
}
|
||||
|
||||
func removeGroupMember(groupId: Int, userId: Int) async throws {
|
||||
_ = try await request("/api/groups/\(groupId)/members/\(userId)", method: "DELETE")
|
||||
}
|
||||
|
||||
func setGroupMemberColor(groupId: Int, userId: Int, color: String) async throws {
|
||||
_ = try await request("/api/groups/\(groupId)/members/\(userId)/color", method: "PUT",
|
||||
body: ["color": color])
|
||||
}
|
||||
|
||||
func fetchGroupCombined(groupId: Int, start: Date, end: Date) async throws -> [CalEvent] {
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
iso.timeZone = TimeZone(abbreviation: "UTC")
|
||||
let s = iso.string(from: start)
|
||||
let e = iso.string(from: end)
|
||||
let sEnc = s.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? s
|
||||
let eEnc = e.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? e
|
||||
let data = try await request("/api/groups/\(groupId)/combined?start=\(sEnc)&end=\(eEnc)")
|
||||
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let arr = root["events"] as? [[String: Any]] else { return [] }
|
||||
return arr.compactMap { CalEvent.from(json: $0) }
|
||||
}
|
||||
|
||||
// MARK: – iCal import / export
|
||||
|
||||
/// Import a .ics file into a local calendar. Returns (imported, skipped, errors).
|
||||
func importICS(calendarId: Int, fileURL: URL) async throws -> (imported: Int, skipped: Int, errors: [String]) {
|
||||
guard let url = URL(string: baseURL + "/api/local/calendars/\(calendarId)/import") else { throw APIError.invalidURL }
|
||||
let fileData = try Data(contentsOf: fileURL)
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
var bodyData = Data()
|
||||
let filename = fileURL.lastPathComponent.isEmpty ? "import.ics" : fileURL.lastPathComponent
|
||||
bodyData.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||
bodyData.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
|
||||
bodyData.append("Content-Type: text/calendar\r\n\r\n".data(using: .utf8)!)
|
||||
bodyData.append(fileData)
|
||||
bodyData.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
||||
req.httpBody = bodyData
|
||||
let (data, response) = try await URLSession.shared.data(for: req)
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if status == 401 { throw APIError.unauthorized }
|
||||
if status >= 400 {
|
||||
let msg = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String ?? "Fehler \(status)"
|
||||
throw APIError.serverError(msg)
|
||||
}
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
let errs = (json?["errors"] as? [String]) ?? []
|
||||
return (json?["imported"] as? Int ?? 0, json?["skipped"] as? Int ?? 0, errs)
|
||||
}
|
||||
|
||||
/// Export a local calendar as raw .ics bytes.
|
||||
func exportICS(calendarId: Int) async throws -> Data {
|
||||
return try await request("/api/local/calendars/\(calendarId)/export")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Keychain access for secrets (currently just the auth bearer token).
|
||||
///
|
||||
/// This replaces the earlier `enum Keychain`, which discarded every `OSStatus`.
|
||||
/// That mattered more than it looks: on Mac Catalyst a missing
|
||||
/// `keychain-access-groups` entitlement makes `SecItemAdd`/`SecItemCopyMatching`
|
||||
/// fail with `errSecMissingEntitlement`, and because nothing checked the status
|
||||
/// the result was indistinguishable from "no token stored" — the user was
|
||||
/// silently signed out on every launch.
|
||||
///
|
||||
/// Items live in a shared access group so the Mac build and other apps from the
|
||||
/// same team can read the token. Reading requires `keychain-access-groups` in
|
||||
/// the entitlements listing `sharedAccessGroup`.
|
||||
enum KeychainStore {
|
||||
/// Access group shared across our apps. Must match an entry in the
|
||||
/// `keychain-access-groups` entitlement of every app that uses it.
|
||||
/// The `PP34X97WS3.` prefix is this account's Team ID — the same value
|
||||
/// `$(AppIdentifierPrefix)` expands to at build time.
|
||||
static let sharedAccessGroup = "PP34X97WS3.com.scarriffleservices.calendarr.shared"
|
||||
|
||||
private static let service = "Calendarr"
|
||||
|
||||
/// Set when a keychain call failed because the entitlement was missing and
|
||||
/// we fell back to the app's own default access group. Signing is a build
|
||||
/// configuration concern, so in DEBUG we make it loud; in release we keep
|
||||
/// working rather than locking the user out of their own account.
|
||||
private(set) nonisolated(unsafe) static var didFallBackToDefaultGroup = false
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Store or delete a value. Passing `nil` deletes it.
|
||||
static func set(_ value: String?, for key: String,
|
||||
accessGroup: String? = sharedAccessGroup) throws {
|
||||
do {
|
||||
try write(value, key: key, accessGroup: accessGroup)
|
||||
} catch KeychainError.status(errSecMissingEntitlement, _) where accessGroup != nil {
|
||||
noteEntitlementFallback()
|
||||
try write(value, key: key, accessGroup: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a value. Returns `nil` when the item simply is not there;
|
||||
/// throws when the keychain itself refused the request.
|
||||
static func get(_ key: String, accessGroup: String? = sharedAccessGroup) throws -> String? {
|
||||
do {
|
||||
return try read(key: key, accessGroup: accessGroup)
|
||||
} catch KeychainError.status(errSecMissingEntitlement, _) where accessGroup != nil {
|
||||
noteEntitlementFallback()
|
||||
return try read(key: key, accessGroup: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Implementation
|
||||
|
||||
private static func baseQuery(key: String, accessGroup: String?) -> [String: Any] {
|
||||
var q: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
// Selects the modern, entitlement-gated keychain on macOS/Catalyst
|
||||
// instead of the legacy file-based login keychain. Documented no-op
|
||||
// on iOS 13+, so it is safe to set unconditionally.
|
||||
kSecUseDataProtectionKeychain as String: true,
|
||||
]
|
||||
if let accessGroup { q[kSecAttrAccessGroup as String] = accessGroup }
|
||||
return q
|
||||
}
|
||||
|
||||
private static func write(_ value: String?, key: String, accessGroup: String?) throws {
|
||||
let base = baseQuery(key: key, accessGroup: accessGroup)
|
||||
|
||||
let deleteStatus = SecItemDelete(base as CFDictionary)
|
||||
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
|
||||
throw KeychainError.status(deleteStatus, operation: "delete \(key)")
|
||||
}
|
||||
|
||||
guard let value, let data = value.data(using: .utf8) else { return }
|
||||
|
||||
var add = base
|
||||
add[kSecValueData as String] = data
|
||||
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
|
||||
let addStatus = SecItemAdd(add as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw KeychainError.status(addStatus, operation: "add \(key)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func read(key: String, accessGroup: String?) throws -> String? {
|
||||
var query = baseQuery(key: key, accessGroup: accessGroup)
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
|
||||
var out: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &out)
|
||||
switch status {
|
||||
case errSecSuccess:
|
||||
guard let data = out as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
case errSecItemNotFound:
|
||||
return nil
|
||||
default:
|
||||
throw KeychainError.status(status, operation: "read \(key)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func noteEntitlementFallback() {
|
||||
guard !didFallBackToDefaultGroup else { return }
|
||||
didFallBackToDefaultGroup = true
|
||||
assertionFailure("""
|
||||
Keychain access group \(sharedAccessGroup) was refused \
|
||||
(errSecMissingEntitlement). Add it to keychain-access-groups and \
|
||||
enable Keychain Sharing on the App ID. Falling back to the app's \
|
||||
own default group — the Mac app will not see this token.
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
enum KeychainError: Error, LocalizedError {
|
||||
case status(OSStatus, operation: String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .status(status, operation):
|
||||
let detail = SecCopyErrorMessageString(status, nil) as String? ?? "OSStatus \(status)"
|
||||
return "Keychain \(operation) failed: \(detail)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
extension Notification.Name {
|
||||
/// Posted when the default-reminder setting changes, so the calendar host
|
||||
/// can recompute the scheduled local notifications from its cached events.
|
||||
static let rescheduleReminders = Notification.Name("rescheduleReminders")
|
||||
}
|
||||
|
||||
/// Schedules local OS notifications for upcoming events. Per-event reminders
|
||||
/// (local events) take precedence; otherwise the user's default reminder applies
|
||||
/// to every event (incl. external). Re-run whenever events or the default change.
|
||||
enum NotificationScheduler {
|
||||
|
||||
static func requestAuthorizationIfNeeded() {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in }
|
||||
}
|
||||
|
||||
/// Recompute and (re)schedule notifications from the given events. The iOS
|
||||
/// pending-notification cap is 64, so only the soonest are scheduled.
|
||||
/// `disabledCalendarKeys` ("source:id") are calendars with reminders turned
|
||||
/// off — their events never generate notifications.
|
||||
static func reschedule(events: [CalEvent], disabledCalendarKeys: Set<String> = []) {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.getNotificationSettings { settings in
|
||||
guard settings.authorizationStatus == .authorized
|
||||
|| settings.authorizationStatus == .provisional else { return }
|
||||
|
||||
let defaultMin = (UserDefaults.standard.object(forKey: "defaultReminderMinutes") as? Int) ?? -1
|
||||
let now = Date()
|
||||
var pending: [(fire: Date, event: CalEvent)] = []
|
||||
for ev in events {
|
||||
// Skip calendars the user muted for reminders.
|
||||
if !disabledCalendarKeys.isEmpty {
|
||||
let key = CalendarStore.calendarKey(source: ev.source, calendarId: ev.calendarId)
|
||||
if disabledCalendarKeys.contains(key) { continue }
|
||||
}
|
||||
let offsets = ev.reminders.isEmpty
|
||||
? (defaultMin >= 0 ? [defaultMin] : [])
|
||||
: ev.reminders
|
||||
for m in offsets {
|
||||
let fire = ev.startDate.addingTimeInterval(-Double(m) * 60)
|
||||
if fire > now { pending.append((fire, ev)) }
|
||||
}
|
||||
}
|
||||
pending.sort { $0.fire < $1.fire }
|
||||
let limited = pending.prefix(60) // stay safely under the 64 system cap
|
||||
|
||||
center.removeAllPendingNotificationRequests()
|
||||
for item in limited {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = item.event.title
|
||||
let minutes = Int(item.event.startDate.timeIntervalSince(item.fire) / 60)
|
||||
let rel = relativeText(minutes)
|
||||
let detail = bodyText(item.event)
|
||||
content.body = detail.isEmpty ? rel : "\(rel) · \(detail)"
|
||||
content.sound = .default
|
||||
let comps = Calendar.current.dateComponents(
|
||||
[.year, .month, .day, .hour, .minute, .second], from: item.fire)
|
||||
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: false)
|
||||
center.add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func relativeText(_ minutes: Int) -> String {
|
||||
if minutes < 60 { return "in \(minutes) Min." }
|
||||
if minutes == 60 { return "in 1 Std." }
|
||||
if minutes < 1440 { return "in \(minutes / 60) Std." }
|
||||
if minutes == 1440 { return "morgen" }
|
||||
return "in \(minutes / 1440) Tagen"
|
||||
}
|
||||
|
||||
private static func bodyText(_ ev: CalEvent) -> String {
|
||||
var parts: [String] = []
|
||||
if !ev.isAllDay {
|
||||
let f = DateFormatter(); f.timeStyle = .short; f.dateStyle = .none
|
||||
parts.append(f.string(from: ev.startDate))
|
||||
}
|
||||
if !ev.location.isEmpty { parts.append(ev.location) }
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import AppAuth
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// Redirect target for the SSO round-trip. Reverse-DNS on the bundle id, per
|
||||
/// RFC 8252; the same value has to be registered in the identity provider.
|
||||
///
|
||||
/// It is deliberately not server-configurable: the app can only ever receive a
|
||||
/// scheme it owns, so the provider has to be registered with this exact value.
|
||||
///
|
||||
/// No `CFBundleURLTypes` entry is needed on the normal path — AppAuth's iOS
|
||||
/// user agent uses `ASWebAuthenticationSession`, which intercepts the callback
|
||||
/// itself. See `guidedAccessBlocksLogin` for the one exception.
|
||||
private let redirectURI = "com.scarriffleservices.calendarr.ios:/oauth2redirect"
|
||||
|
||||
/// Keychain key for the serialised `OIDAuthState` (contains the refresh token).
|
||||
private let authStateKey = "oidcAuthState"
|
||||
/// UserDefaults key for the provider that SSO session belongs to.
|
||||
let oidcProviderDefaultsKey = "oidcProvider"
|
||||
|
||||
/// A provider offered by the Calendarr server (`GET /api/auth/oidc/providers`).
|
||||
struct OIDCProvider: Identifiable, Equatable {
|
||||
let key: String
|
||||
let name: String
|
||||
let issuer: String
|
||||
/// Exactly what the server told us to request. The app must not add scopes
|
||||
/// of its own — one the provider does not know makes it reject the whole
|
||||
/// authorization request.
|
||||
let scopes: String
|
||||
let mobileClientID: String
|
||||
|
||||
var id: String { key }
|
||||
}
|
||||
|
||||
/// An SSO failure carrying the server's stable slug, which `LoginView` maps to
|
||||
/// a translated message. `underlying` is for the log, never for the user.
|
||||
struct OIDCError: LocalizedError {
|
||||
let slug: String
|
||||
var underlying: String?
|
||||
var errorDescription: String? { slug }
|
||||
}
|
||||
|
||||
/// Result of a completed provider flow, ready to be exchanged with Calendarr.
|
||||
struct OIDCTokens {
|
||||
let idToken: String
|
||||
let accessToken: String?
|
||||
let nonce: String?
|
||||
/// Serialised `OIDAuthState`, including the refresh token.
|
||||
let serialisedState: String?
|
||||
}
|
||||
|
||||
/// Drives the OpenID Connect Authorization Code Flow with PKCE.
|
||||
///
|
||||
/// The login page always opens in `ASWebAuthenticationSession` (AppAuth's
|
||||
/// default external user agent on iOS) — never in an embedded web view, so the
|
||||
/// credentials stay in the browser's security context and the provider's own
|
||||
/// session cookies keep working.
|
||||
@MainActor
|
||||
final class OIDCAuthenticator {
|
||||
|
||||
/// AppAuth requires the in-flight session to stay alive for the round-trip.
|
||||
private var session: OIDExternalUserAgentSession?
|
||||
|
||||
/// Under Guided Access, AppAuth falls back to `SFSafariViewController`,
|
||||
/// whose redirect can only come back through a registered URL scheme — and
|
||||
/// the app target generates its Info.plist, which has no way to declare
|
||||
/// `CFBundleURLTypes`. Rather than hang forever waiting for a callback that
|
||||
/// cannot arrive, refuse up front.
|
||||
var guidedAccessBlocksLogin: Bool { UIAccessibility.isGuidedAccessEnabled }
|
||||
|
||||
func discover(issuer: String) async throws -> OIDServiceConfiguration {
|
||||
guard let url = URL(string: issuer) else { throw OIDCError(slug: "oidc_bad_issuer") }
|
||||
return try await withCheckedThrowingContinuation { cont in
|
||||
OIDAuthorizationService.discoverConfiguration(forIssuer: url) { config, error in
|
||||
if let config {
|
||||
cont.resume(returning: config)
|
||||
} else {
|
||||
cont.resume(throwing: OIDCError(slug: "oidc_provider_unreachable",
|
||||
underlying: Self.describe(error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the full flow and return the provider's tokens.
|
||||
///
|
||||
/// `OIDAuthorizationRequest` generates state, nonce and the PKCE verifier
|
||||
/// itself. The nonce has to travel to our server with the ID token so it
|
||||
/// can verify the binding — this is the app's replay protection.
|
||||
func authorize(provider: OIDCProvider) async throws -> OIDCTokens {
|
||||
if guidedAccessBlocksLogin { throw OIDCError(slug: "oidc_guided_access") }
|
||||
|
||||
let config = try await discover(issuer: provider.issuer)
|
||||
|
||||
var scopes = provider.scopes
|
||||
.split(separator: " ")
|
||||
.map(String.init)
|
||||
.filter { !$0.isEmpty }
|
||||
if !scopes.contains(OIDScopeOpenID) { scopes.append(OIDScopeOpenID) }
|
||||
|
||||
guard let redirectURL = URL(string: redirectURI) else {
|
||||
throw OIDCError(slug: "oidc_bad_redirect")
|
||||
}
|
||||
|
||||
let request = OIDAuthorizationRequest(
|
||||
configuration: config,
|
||||
clientId: provider.mobileClientID,
|
||||
clientSecret: nil, // public client — never send a secret
|
||||
scopes: scopes,
|
||||
redirectURL: redirectURL,
|
||||
responseType: OIDResponseTypeCode,
|
||||
additionalParameters: nil
|
||||
)
|
||||
|
||||
guard let presenter = Self.topViewController() else {
|
||||
throw OIDCError(slug: "oidc_no_presenter")
|
||||
}
|
||||
|
||||
// Clear the retained session on every exit, not just the happy one.
|
||||
defer { session = nil }
|
||||
|
||||
let authState: OIDAuthState = try await withCheckedThrowingContinuation { cont in
|
||||
session = OIDAuthState.authState(byPresenting: request,
|
||||
presenting: presenter) { state, error in
|
||||
if let state {
|
||||
cont.resume(returning: state)
|
||||
} else if Self.isCancellation(error) {
|
||||
cont.resume(throwing: OIDCError(slug: "oidc_cancelled"))
|
||||
} else {
|
||||
cont.resume(throwing: OIDCError(slug: "oidc_failed",
|
||||
underlying: Self.describe(error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard let idToken = authState.lastTokenResponse?.idToken else {
|
||||
throw OIDCError(slug: "oidc_no_id_token")
|
||||
}
|
||||
|
||||
return OIDCTokens(
|
||||
idToken: idToken,
|
||||
accessToken: authState.lastTokenResponse?.accessToken,
|
||||
nonce: request.nonce,
|
||||
serialisedState: Self.serialise(authState)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Silent renewal
|
||||
|
||||
/// Ask the provider for a fresh ID token using the stored refresh token.
|
||||
///
|
||||
/// Returns nil when there is no SSO session to renew. The refreshed state
|
||||
/// is written back, because the provider may have rotated the refresh
|
||||
/// token.
|
||||
func renewIDToken() async throws -> (idToken: String, accessToken: String?, nonce: String?)? {
|
||||
guard let stored = try? KeychainStore.get(authStateKey),
|
||||
let state = Self.deserialise(stored) else { return nil }
|
||||
|
||||
let tokens: (String?, String?) = try await withCheckedThrowingContinuation { cont in
|
||||
state.performAction { accessToken, idToken, error in
|
||||
if let error {
|
||||
cont.resume(throwing: OIDCError(slug: "oidc_refresh_failed",
|
||||
underlying: Self.describe(error)))
|
||||
} else {
|
||||
cont.resume(returning: (idToken, accessToken))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard let idToken = tokens.0 else { return nil }
|
||||
if let refreshed = Self.serialise(state) {
|
||||
try? KeychainStore.set(refreshed, for: authStateKey)
|
||||
}
|
||||
// The original request travels inside the stored state, so the nonce
|
||||
// is still available. A refreshed token often drops the claim, which
|
||||
// the server tolerates — but when it keeps it, it has to match.
|
||||
let nonce = state.lastAuthorizationResponse.request.nonce
|
||||
return (idToken, tokens.1, nonce)
|
||||
}
|
||||
|
||||
/// Persist the SSO session so it can be renewed without another browser trip.
|
||||
static func store(state: String?, providerKey: String) {
|
||||
guard let state else { return }
|
||||
try? KeychainStore.set(state, for: authStateKey)
|
||||
UserDefaults.standard.set(providerKey, forKey: oidcProviderDefaultsKey)
|
||||
}
|
||||
|
||||
/// The provider key of the active SSO session, if any.
|
||||
static var storedProviderKey: String? {
|
||||
UserDefaults.standard.string(forKey: oidcProviderDefaultsKey)
|
||||
}
|
||||
|
||||
static func clearStoredSession() {
|
||||
try? KeychainStore.set(nil, for: authStateKey)
|
||||
// Mirror the authToken handling: a pre-entitlement copy may exist in
|
||||
// the app's own keychain group.
|
||||
try? KeychainStore.set(nil, for: authStateKey, accessGroup: nil)
|
||||
UserDefaults.standard.removeObject(forKey: oidcProviderDefaultsKey)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func serialise(_ state: OIDAuthState) -> String? {
|
||||
do {
|
||||
let data = try NSKeyedArchiver.archivedData(withRootObject: state,
|
||||
requiringSecureCoding: true)
|
||||
return data.base64EncodedString()
|
||||
} catch {
|
||||
log("archiving OIDAuthState failed: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func deserialise(_ raw: String) -> OIDAuthState? {
|
||||
guard let data = Data(base64Encoded: raw) else { return nil }
|
||||
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: OIDAuthState.self, from: data)
|
||||
}
|
||||
|
||||
/// AppAuth reports both a user-initiated and a programmatic cancel; neither
|
||||
/// is a failure worth showing. Checking the domain as well as the code
|
||||
/// avoids matching an unrelated error that happens to share the number.
|
||||
private static func isCancellation(_ error: Error?) -> Bool {
|
||||
guard let ns = error as NSError? else { return false }
|
||||
guard ns.domain == OIDGeneralErrorDomain else { return false }
|
||||
return ns.code == OIDErrorCode.userCanceledAuthorizationFlow.rawValue
|
||||
|| ns.code == OIDErrorCode.programCanceledAuthorizationFlow.rawValue
|
||||
}
|
||||
|
||||
/// Detail for the log only — never shown to the user.
|
||||
private static func describe(_ error: Error?) -> String? {
|
||||
guard let ns = error as NSError? else { return nil }
|
||||
let oauth = ns.userInfo[OIDOAuthErrorResponseErrorKey] as? [String: Any]
|
||||
let detail = (oauth?["error"] as? String).map { " oauth=\($0)" } ?? ""
|
||||
let text = "\(ns.domain)/\(ns.code): \(ns.localizedDescription)\(detail)"
|
||||
log(text)
|
||||
return text
|
||||
}
|
||||
|
||||
private static func log(_ message: String) {
|
||||
#if DEBUG
|
||||
print("[OIDC] \(message)")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Topmost view controller to present the browser sheet from. Works under
|
||||
/// Mac Catalyst too, where a presentation anchor is likewise required.
|
||||
private static func topViewController() -> UIViewController? {
|
||||
let scene = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.first { $0.activationState == .foregroundActive }
|
||||
?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
|
||||
|
||||
var top = scene?.keyWindow?.rootViewController
|
||||
while let presented = top?.presentedViewController {
|
||||
top = presented
|
||||
}
|
||||
return top
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Foundation
|
||||
|
||||
/// Keeps an SSO session alive without sending the user back through the browser.
|
||||
///
|
||||
/// Calendarr's own token has no refresh mechanism — it simply expires. What the
|
||||
/// `offline_access` scope buys is a refresh token at the *identity provider*, so
|
||||
/// renewal means: get a fresh ID token from the provider, then trade it for a
|
||||
/// fresh Calendarr token at `/api/auth/oidc/exchange`.
|
||||
@MainActor
|
||||
enum OIDCSessionRefresher {
|
||||
|
||||
/// Renew when less than this much of the Calendarr token's life is left.
|
||||
private static let renewWithin: TimeInterval = 24 * 60 * 60
|
||||
|
||||
/// Best-effort renewal at launch. Any failure is swallowed: the existing
|
||||
/// token is still valid, and a hard failure would only surface later as a
|
||||
/// normal 401.
|
||||
static func refreshIfNeeded(_ appState: AppState) async {
|
||||
guard !appState.authToken.isEmpty,
|
||||
let providerKey = OIDCAuthenticator.storedProviderKey,
|
||||
expiresSoon(appState.authToken) else { return }
|
||||
|
||||
do {
|
||||
let authenticator = OIDCAuthenticator()
|
||||
guard let renewed = try await authenticator.renewIDToken() else { return }
|
||||
|
||||
let providers = try await CalendarrAPI.oidcProviders(baseURL: appState.serverURL)
|
||||
guard let provider = providers.first(where: { $0.key == providerKey }) else { return }
|
||||
|
||||
let result = try await CalendarrAPI.exchangeOIDC(
|
||||
baseURL: appState.serverURL,
|
||||
provider: provider.key,
|
||||
clientID: provider.mobileClientID,
|
||||
idToken: renewed.idToken,
|
||||
accessToken: renewed.accessToken,
|
||||
nonce: renewed.nonce
|
||||
)
|
||||
appState.saveLogin(token: result.token, user: result.username, admin: result.isAdmin)
|
||||
} catch {
|
||||
// Nothing to do — keep using the token we have.
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the Calendarr JWT expires within [renewWithin].
|
||||
///
|
||||
/// The payload is read without verifying the signature: this only decides
|
||||
/// *whether* to renew, and the server validates the token on every request
|
||||
/// regardless. A token we cannot parse is left alone.
|
||||
private static func expiresSoon(_ jwt: String) -> Bool {
|
||||
let parts = jwt.split(separator: ".")
|
||||
guard parts.count == 3 else { return false }
|
||||
|
||||
var payload = String(parts[1])
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
// Base64url drops the padding that Data(base64Encoded:) insists on.
|
||||
while payload.count % 4 != 0 { payload += "=" }
|
||||
|
||||
guard let data = Data(base64Encoded: payload),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let exp = json["exp"] as? Double else { return false }
|
||||
|
||||
return Date(timeIntervalSince1970: exp).timeIntervalSinceNow < renewWithin
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import Foundation
|
||||
|
||||
extension Notification.Name {
|
||||
/// Posted after a successful pull applied new settings to UserDefaults, so
|
||||
/// views holding live state (CalendarHostView → store, widgets) can react.
|
||||
static let settingsDidChange = Notification.Name("settingsDidChange")
|
||||
}
|
||||
|
||||
/// Per-setting cross-device synchronisation. The server is the sole authority for
|
||||
/// WHICH settings sync (its `sync_flags` map); this client keeps a cached copy of
|
||||
/// that map and only sends/applies the keys whose flag is on. See
|
||||
/// backend/SETTINGS_SYNC.md for the shared contract.
|
||||
///
|
||||
/// - Pull: for every syncable key whose flag is on, the server value wins.
|
||||
/// - Push (debounced, read-modify-write): start from the server snapshot,
|
||||
/// overwrite only the on-flagged keys with the local value, PUT.
|
||||
enum SettingsSync {
|
||||
|
||||
/// Server keys this iOS client can sync, mapped to their UserDefaults key.
|
||||
/// Intentionally excluded: `language` (iOS "system" has no server value),
|
||||
/// `share_calendar_icon` (no iOS UI), text/line contrast (iOS-only opacity
|
||||
/// controls kept device-local), `liquid_glass` (iOS-only).
|
||||
static let keyToDefaults: [String: String] = [
|
||||
"default_view": "defaultView",
|
||||
"week_start_day": "weekStartDay",
|
||||
"dim_past_events": "dimPastEvents",
|
||||
"hour_height": "hourHeight",
|
||||
"default_event_duration_minutes": "defaultEventDurationMinutes",
|
||||
"default_reminder_minutes": "defaultReminderMinutes",
|
||||
"primary_color": "primaryColor",
|
||||
"accent_color": "accentColor",
|
||||
"today_color": "todayColor",
|
||||
"text_color": "textColor",
|
||||
"bg_color": "backgroundColor",
|
||||
"line_color": "lineColor",
|
||||
"month_divider_color": "monthDividerColor",
|
||||
"month_label_color": "monthLabelColor",
|
||||
"cache_months": "cacheMonths",
|
||||
"month_view_paged": "monthViewPaged",
|
||||
]
|
||||
|
||||
static let syncableKeys: [String] = Array(keyToDefaults.keys)
|
||||
|
||||
/// Fallback flags used only until the first server pull populates the real map
|
||||
/// (mirrors the server's DEFAULT_SYNC for the iOS-managed keys).
|
||||
static let defaultSync: [String: Bool] = [
|
||||
"default_view": true, "week_start_day": true, "dim_past_events": true,
|
||||
"hour_height": true, "default_event_duration_minutes": true,
|
||||
"default_reminder_minutes": true, "primary_color": true, "accent_color": true,
|
||||
"today_color": true, "text_color": true, "bg_color": true, "line_color": true,
|
||||
"month_divider_color": true, "month_label_color": true,
|
||||
"cache_months": false, "month_view_paged": false,
|
||||
]
|
||||
|
||||
// MARK: – Flag storage (UserDefaults JSON, mirrors the server's resolved map)
|
||||
|
||||
private static let flagsKey = "settingsSyncFlags"
|
||||
|
||||
/// Effective flags: stored overrides on top of the fallback defaults.
|
||||
static func flags() -> [String: Bool] {
|
||||
var result = defaultSync
|
||||
if let data = UserDefaults.standard.data(forKey: flagsKey),
|
||||
let stored = try? JSONDecoder().decode([String: Bool].self, from: data) {
|
||||
for (k, v) in stored { result[k] = v }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
static func isSynced(_ key: String) -> Bool { flags()[key] ?? false }
|
||||
|
||||
private static func writeFlags(_ f: [String: Bool]) {
|
||||
if let data = try? JSONEncoder().encode(f) {
|
||||
UserDefaults.standard.set(data, forKey: flagsKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the cached flags with the server's resolved map (known keys only).
|
||||
static func storeServerFlags(_ server: [String: Bool]?) {
|
||||
guard let server else { return }
|
||||
var f = flags()
|
||||
for key in syncableKeys { if let v = server[key] { f[key] = v } }
|
||||
writeFlags(f)
|
||||
}
|
||||
|
||||
/// Toggle one setting's sync flag. Turning it on pushes this device's current
|
||||
/// value up (it becomes the shared value); turning off keeps the value local.
|
||||
static func setSynced(_ key: String, _ on: Bool, api: CalendarrAPI) {
|
||||
var f = flags(); f[key] = on; writeFlags(f)
|
||||
push(api: api)
|
||||
}
|
||||
|
||||
/// The global "sync everything" switch.
|
||||
static func setAllSynced(_ on: Bool, api: CalendarrAPI) {
|
||||
var f = flags(); for key in syncableKeys { f[key] = on }; writeFlags(f)
|
||||
push(api: api)
|
||||
}
|
||||
|
||||
// MARK: – Local ⇄ AppSettings field mapping
|
||||
|
||||
private static func str(_ key: String, _ fallback: String) -> String {
|
||||
UserDefaults.standard.string(forKey: key) ?? fallback
|
||||
}
|
||||
private static func int(_ key: String, _ fallback: Int) -> Int {
|
||||
UserDefaults.standard.object(forKey: key) as? Int ?? fallback
|
||||
}
|
||||
|
||||
/// Build an AppSettings snapshot from local UserDefaults.
|
||||
static func currentSettings() -> AppSettings {
|
||||
var s = AppSettings()
|
||||
s.primaryColor = str("primaryColor", "#4285f4")
|
||||
s.accentColor = str("accentColor", "#ea4335")
|
||||
s.todayColor = str("todayColor", "#4285f4")
|
||||
s.textColor = str("textColor", "#FFFFFF")
|
||||
s.backgroundColor = str("backgroundColor", "#000000")
|
||||
s.lineColor = str("lineColor", "#3A3A52")
|
||||
s.monthDividerColor = str("monthDividerColor", "#7090c0")
|
||||
s.monthLabelColor = str("monthLabelColor", "#7090c0")
|
||||
s.hourHeight = int("hourHeight", 60)
|
||||
s.defaultView = str("defaultView", "month")
|
||||
s.weekStartDay = str("weekStartDay", "monday")
|
||||
s.dimPastEvents = UserDefaults.standard.bool(forKey: "dimPastEvents")
|
||||
s.cacheMonths = int("cacheMonths", 3)
|
||||
s.monthViewPaged = UserDefaults.standard.bool(forKey: "monthViewPaged")
|
||||
let rem = int("defaultReminderMinutes", -1)
|
||||
s.defaultReminderMinutes = rem < 0 ? nil : rem
|
||||
s.defaultEventDurationMinutes = int("defaultEventDurationMinutes", 60)
|
||||
return s
|
||||
}
|
||||
|
||||
/// Copy one synced field from a source snapshot into a destination snapshot.
|
||||
private static func copyField(_ key: String, from src: AppSettings, into dst: inout AppSettings) {
|
||||
switch key {
|
||||
case "default_view": dst.defaultView = src.defaultView
|
||||
case "week_start_day": dst.weekStartDay = src.weekStartDay
|
||||
case "dim_past_events": dst.dimPastEvents = src.dimPastEvents
|
||||
case "hour_height": dst.hourHeight = src.hourHeight
|
||||
case "default_event_duration_minutes": dst.defaultEventDurationMinutes = src.defaultEventDurationMinutes
|
||||
case "default_reminder_minutes": dst.defaultReminderMinutes = src.defaultReminderMinutes
|
||||
case "primary_color": dst.primaryColor = src.primaryColor
|
||||
case "accent_color": dst.accentColor = src.accentColor
|
||||
case "today_color": dst.todayColor = src.todayColor
|
||||
case "text_color": dst.textColor = src.textColor
|
||||
case "bg_color": dst.backgroundColor = src.backgroundColor
|
||||
case "line_color": dst.lineColor = src.lineColor
|
||||
case "month_divider_color": dst.monthDividerColor = src.monthDividerColor
|
||||
case "month_label_color": dst.monthLabelColor = src.monthLabelColor
|
||||
case "cache_months": dst.cacheMonths = src.cacheMonths
|
||||
case "month_view_paged": dst.monthViewPaged = src.monthViewPaged
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
/// Write one synced field from a server snapshot into local UserDefaults.
|
||||
private static func applyField(_ key: String, from s: AppSettings) {
|
||||
let d = UserDefaults.standard
|
||||
switch key {
|
||||
case "default_view": d.set(s.defaultView, forKey: "defaultView")
|
||||
case "week_start_day": d.set(s.weekStartDay, forKey: "weekStartDay")
|
||||
case "dim_past_events": d.set(s.dimPastEvents, forKey: "dimPastEvents")
|
||||
case "hour_height": d.set(s.hourHeight, forKey: "hourHeight")
|
||||
case "default_event_duration_minutes": d.set(s.defaultEventDurationMinutes, forKey: "defaultEventDurationMinutes")
|
||||
case "default_reminder_minutes": d.set(s.defaultReminderMinutes ?? -1, forKey: "defaultReminderMinutes")
|
||||
case "primary_color": d.set(s.primaryColor, forKey: "primaryColor")
|
||||
case "accent_color": d.set(s.accentColor, forKey: "accentColor")
|
||||
case "today_color": d.set(s.todayColor, forKey: "todayColor")
|
||||
case "text_color": d.set(s.textColor, forKey: "textColor")
|
||||
case "bg_color": d.set(s.backgroundColor, forKey: "backgroundColor")
|
||||
case "line_color": d.set(s.lineColor, forKey: "lineColor")
|
||||
case "month_divider_color": d.set(s.monthDividerColor, forKey: "monthDividerColor")
|
||||
case "month_label_color": d.set(s.monthLabelColor, forKey: "monthLabelColor")
|
||||
case "cache_months": d.set(s.cacheMonths, forKey: "cacheMonths")
|
||||
case "month_view_paged": d.set(s.monthViewPaged, forKey: "monthViewPaged")
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Pull
|
||||
|
||||
/// Fetch the server's settings, refresh the flag cache, and apply the value of
|
||||
/// every on-flagged key locally (server wins).
|
||||
static func pull(api: CalendarrAPI) async {
|
||||
guard let server = try? await api.getSettings() else { return }
|
||||
storeServerFlags(server.syncFlags)
|
||||
let f = flags()
|
||||
for key in syncableKeys where f[key] == true {
|
||||
applyField(key, from: server)
|
||||
}
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .settingsDidChange, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Push (debounced)
|
||||
|
||||
private static var pushTask: Task<Void, Never>?
|
||||
|
||||
/// Schedule a debounced push. Repeated calls (e.g. while dragging a colour
|
||||
/// slider) collapse into a single network write ~1.2 s after the last edit.
|
||||
static func push(api: CalendarrAPI) {
|
||||
pushTask?.cancel()
|
||||
pushTask = Task {
|
||||
try? await Task.sleep(for: .milliseconds(1200))
|
||||
if Task.isCancelled { return }
|
||||
await performPush(api: api)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-modify-write: start from the server's current settings so unsynced
|
||||
/// fields stay intact, overwrite only the on-flagged keys with local values,
|
||||
/// and send the current flag map so the account-wide config stays in sync.
|
||||
private static func performPush(api: CalendarrAPI) async {
|
||||
guard var merged = try? await api.getSettings() else { return }
|
||||
let local = currentSettings()
|
||||
let f = flags()
|
||||
for key in syncableKeys where f[key] == true {
|
||||
copyField(key, from: local, into: &merged)
|
||||
}
|
||||
merged.syncFlags = f
|
||||
try? await api.updateSettings(merged)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user