Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ included:
- Data
- Domain
- Presentation
- Shared
- Widgets
- StudyAppTests
excluded:
- Presentation/Resources
Expand Down
12 changes: 12 additions & 0 deletions App/StudyAppApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ struct StudyAppApp: App {
let target = SearchTarget(identifier: identifier) else { return }
navigation.open(target)
}
.onOpenURL { url in
guard let link = StudyAppLink(url: url) else { return }
navigation.open(link)
}
.task { await keepClockRunning() }
.task { await keepSourcesFresh() }
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
Expand Down Expand Up @@ -215,6 +219,7 @@ struct StudyAppApp: App {
await studIPViewModel.refreshSessionState()
await studIPViewModel.syncIfDue()
await reindexSearch()
publishWidgetSnapshot()
await backupViewModel.runAutomaticBackupIfDue()
await notificationsViewModel.refresh()
}
Expand All @@ -230,6 +235,11 @@ struct StudyAppApp: App {
try? await container.makeReindexSearch()()
}

/// Hands the current lectures, due cards and exams to the widgets.
private func publishWidgetSnapshot() {
WidgetSnapshotPublisher().publish(calendar: calendarViewModel, today: todayViewModel, exams: examsViewModel)
}

/// Opens what a clicked notification refers to.
private static func open(_ target: NotificationTarget, navigation: AppNavigation) {
NSApplication.shared.activate()
Expand Down Expand Up @@ -259,6 +269,8 @@ struct StudyAppApp: App {
// Switches to tomorrow's menu after lunch; cached, so usually no request.
await mensaViewModel.refresh()
await reindexSearch()
todayViewModel.load()
publishWidgetSnapshot()
// Picks up new or moved lectures and assignments.
await notificationsViewModel.refresh()
}
Expand Down
21 changes: 21 additions & 0 deletions App/WidgetSnapshotPublisher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation
import WidgetKit

/// Writes the widget snapshot and reloads the widgets when it changed.
@MainActor
struct WidgetSnapshotPublisher {
var url: URL? = WidgetSnapshotStore.defaultURL

func publish(calendar: StudyCalendarViewModel, today: TodayViewModel, exams: ExamsViewModel) {
let snapshot = WidgetSnapshotBuilder.snapshot(
events: calendar.events,
modules: today.modules,
dueCounts: today.dueCounts,
exams: exams.upcoming,
now: calendar.referenceDate
)
// Without an App Group container (e.g. unsigned builds) there are simply no widget updates.
guard (try? WidgetSnapshotStore.save(snapshot, to: url)) == true else { return }
WidgetCenter.shared.reloadAllTimelines()
}
}
11 changes: 11 additions & 0 deletions Config/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>germany.oldenburg.mueller.tristan.StudyApp</string>
<key>CFBundleURLSchemes</key>
<array>
<string>studyapp</string>
</array>
</dict>
</array>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUEnableInstallerLauncherService</key>
Expand Down
5 changes: 5 additions & 0 deletions Config/StudyApp.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<!-- Shared with the widget extension for the widget snapshot (WidgetSnapshotStore.appGroup). -->
<key>com.apple.security.application-groups</key>
<array>
<string>VF66Y42L4U.germany.oldenburg.mueller.tristan.StudyApp</string>
</array>
<!-- Sparkle startet den Installer außerhalb der Sandbox über diese beiden Dienste. -->
<!-- Die App ist ad hoc signiert (ohne Team-ID), Sparkle.framework dagegen mit der Team-ID der
Sparkle-Entwickler. Ohne diese Ausnahme verweigert die Hardened Runtime das Laden beim Start. -->
Expand Down
11 changes: 11 additions & 0 deletions Config/StudyAppWidgets-Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?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>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
13 changes: 13 additions & 0 deletions Config/StudyAppWidgets.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?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>com.apple.security.app-sandbox</key>
<true/>
<!-- The app writes the widget snapshot here (WidgetSnapshotStore.appGroup). -->
<key>com.apple.security.application-groups</key>
<array>
<string>VF66Y42L4U.germany.oldenburg.mueller.tristan.StudyApp</string>
</array>
</dict>
</plist>
11 changes: 11 additions & 0 deletions Presentation/Navigation/AppNavigation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,17 @@ final class AppNavigation {
}
}

/// Opens what a widget link points to; unknown sections fall back to the module overview.
func open(_ link: StudyAppLink) {
switch link {
case .today:
showToday()
case .module(let id, let rawSection):
let section = ModuleSection(rawValue: rawSection) ?? .overview
show(module: id, section: section, intent: section == .slides ? .resumeSlides : nil)
}
}

func go(to location: NavigationLocation) {
navigate(to: location)
}
Expand Down
42 changes: 42 additions & 0 deletions Presentation/Widgets/WidgetSnapshotBuilder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Foundation

/// Turns the app's current state into the snapshot the widgets read.
enum WidgetSnapshotBuilder {
static let lectureCount = 4
static let examCount = 3

static func snapshot(
events: [StudyCalendarEvent],
modules: [StudyModule],
dueCounts: [UUID: Int],
exams: [ExamOverview],
now: Date
) -> WidgetSnapshot {
let active = Dictionary(uniqueKeysWithValues: modules.filter { !$0.isArchived }.map { ($0.id, $0) })
let lectures = events
.filter { !$0.isAllDay && $0.moduleID.map { active[$0] != nil } == true }
.upcoming(after: now, limit: lectureCount)
.compactMap { event -> WidgetSnapshot.Lecture? in
guard let moduleID = event.moduleID, let module = active[moduleID] else { return nil }
return WidgetSnapshot.Lecture(
moduleID: module.id, moduleName: module.name, location: event.location, start: event.startDate, end: event.endDate
)
}
let dueModules = dueCounts
.compactMap { id, due -> WidgetSnapshot.DueModule? in
guard due > 0, let module = active[id] else { return nil }
return WidgetSnapshot.DueModule(moduleID: id, moduleName: module.name, count: due)
}
.sorted { $0.count != $1.count ? $0.count > $1.count : $0.moduleName < $1.moduleName }
let upcomingExams = exams.prefix(examCount).map { overview in
WidgetSnapshot.Exam(
moduleID: overview.module.id,
moduleName: overview.module.name,
title: overview.exam.title,
date: overview.exam.startDate,
progress: overview.plan.progress
)
}
return WidgetSnapshot(lectures: lectures, dueModules: dueModules, exams: Array(upcomingExams))
}
}
101 changes: 101 additions & 0 deletions Shared/WidgetSnapshot.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import Foundation

/// What the widgets show. The app writes it into the shared App Group container; the widget extension
/// only reads it, so widgets never touch the database.
nonisolated struct WidgetSnapshot: Codable, Equatable, Sendable {
struct Lecture: Codable, Equatable, Sendable {
let moduleID: UUID
let moduleName: String
let location: String
let start: Date
let end: Date
}

struct DueModule: Codable, Equatable, Sendable {
let moduleID: UUID
let moduleName: String
let count: Int
}

struct Exam: Codable, Equatable, Sendable {
let moduleID: UUID
let moduleName: String
let title: String
let date: Date
/// Share of the study plan already done, 0…1.
let progress: Double
}

/// The next few lectures, so the widget timeline can move on without the app.
var lectures: [Lecture] = []
var dueModules: [DueModule] = []
var exams: [Exam] = []

var dueCards: Int { dueModules.reduce(0) { $0 + $1.count } }

/// The lecture running at `date`, otherwise the next one.
func lecture(at date: Date) -> Lecture? {
lectures.first { $0.end > date }
}

/// Moments at which the lecture widget changes: starts and ends of the stored lectures.
func changeDates(after date: Date) -> [Date] {
lectures.flatMap { [$0.start, $0.end] }.filter { $0 > date }.sorted()
}
}

/// Reads and writes the snapshot in the App Group container shared by the app and its widgets.
nonisolated enum WidgetSnapshotStore {
/// macOS App Group identifiers are prefixed with the team ID.
static let appGroup = "VF66Y42L4U.germany.oldenburg.mueller.tristan.StudyApp"
static let fileName = "widget-snapshot.json"

static var defaultURL: URL? {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup)?.appending(path: fileName)
}

static func load(from url: URL?) -> WidgetSnapshot? {
guard let url, let data = try? Data(contentsOf: url) else { return nil }
return try? JSONDecoder().decode(WidgetSnapshot.self, from: data)
}

/// - Returns: `true` when the stored snapshot changed, so widgets only reload when needed.
@discardableResult
static func save(_ snapshot: WidgetSnapshot, to url: URL?) throws -> Bool {
guard let url else { return false }
guard load(from: url) != snapshot else { return false }
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try JSONEncoder().encode(snapshot).write(to: url, options: .atomic)
return true
}
}

/// Links from widgets into the app: `studyapp://today` or `studyapp://module/<id>/<section>`.
nonisolated enum StudyAppLink: Equatable, Sendable {
static let scheme = "studyapp"

case today
/// `section` is a `ModuleSection` raw value such as `slides`, `flashcards` or `exam`.
case module(UUID, section: String)

var url: URL {
switch self {
case .today: URL(string: "\(Self.scheme)://today")!
case .module(let id, let section): URL(string: "\(Self.scheme)://module/\(id.uuidString)/\(section)")!
}
}

init?(url: URL) {
guard url.scheme == Self.scheme else { return nil }
let parts = [url.host ?? ""] + url.pathComponents.filter { $0 != "/" }
switch parts.first {
case "today":
self = .today
case "module":
guard parts.count == 3, let id = UUID(uuidString: parts[1]) else { return nil }
self = .module(id, section: parts[2])
default:
return nil
}
}
}
Loading
Loading