krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 08c0099083

08c0099083bdaefdf673453b8c2875fedcb094aa

parent: c17e16cfba

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-09 05:46 UTC

iOS: agenda snapshot for widgets, orgstar:// links, clock Live Activity

The app writes the next seven days to the app group when the agenda's
inputs change and reloads widget timelines. orgstar://agenda, entry and
clock links open the agenda, an entry, or the clocked entry. A Live
Activity follows the running clock; its Clock Out intent runs the app's
clock-out.

Layout: unified · split

Sources/OrgApp/AgendaSnapshot.swift +32
@@ -47,6 +47,30 @@ public struct AgendaSnapshot: Codable, Equatable, Sendable {
47 public var priorityColor: Hex? 47 public var priorityColor: Hex?
48 public var categoryColor: Hex? 48 public var categoryColor: Hex?
49 49
50 public init(
51 id: String, gutter: String, status: String, title: String, category: String, todo: String?, priority: String?, isDone: Bool,
52 isEvent: Bool, start: Date?, end: Date?, path: String?, offset: Int?, statusColor: Hex?, todoColor: Hex?, priorityColor: Hex?,
53 categoryColor: Hex?
54 ) {
55 self.id = id
56 self.gutter = gutter
57 self.status = status
58 self.title = title
59 self.category = category
60 self.todo = todo
61 self.priority = priority
62 self.isDone = isDone
63 self.isEvent = isEvent
64 self.start = start
65 self.end = end
66 self.path = path
67 self.offset = offset
68 self.statusColor = statusColor
69 self.todoColor = todoColor
70 self.priorityColor = priorityColor
71 self.categoryColor = categoryColor
72 }
73
50 public var link: AppLink? { 74 public var link: AppLink? {
51 guard let path, let offset else { return nil } 75 guard let path, let offset else { return nil }
52 return .entry(path: path, offset: offset) 76 return .entry(path: path, offset: offset)
@@ -67,6 +91,14 @@ public struct AgendaSnapshot: Codable, Equatable, Sendable {
67 public var rows: [Row] 91 public var rows: [Row]
68 92
69 public var id: Int { day } 93 public var id: Int { day }
94
95 public init(day: Int, date: Date, header: String, overdue: [Row], rows: [Row]) {
96 self.day = day
97 self.date = date
98 self.header = header
99 self.overdue = overdue
100 self.rows = rows
101 }
70 } 102 }
71 103
72 public var generated: Date 104 public var generated: Date
Sources/OrgApp/ClockActivity.swift added +48
@@ -0,0 +1,48 @@
1#if os(iOS)
2import ActivityKit
3import AppIntents
4import Foundation
5
6/// The running clock's Live Activity, which the app starts and the widget extension draws.
7public struct ClockActivityAttributes: ActivityAttributes {
8 public struct ContentState: Codable, Hashable, Sendable {
9 public var heading: String
10 public var startedAt: Date
11
12 public init(heading: String, startedAt: Date) {
13 self.heading = heading
14 self.startedAt = startedAt
15 }
16 }
17
18 /// The clock's file and the stamp in its `CLOCK: [start]` line, which name the clock.
19 public var path: String
20 public var start: String
21
22 public init(path: String, start: String) {
23 self.path = path
24 self.start = start
25 }
26}
27
28/// The Live Activity's Clock Out button. It runs in the app's process, which sets `handler`.
29public struct ClockOutIntent: LiveActivityIntent {
30 public static let title: LocalizedStringResource = "Clock Out"
31 public static let description = IntentDescription("Stops the running clock.")
32 public static let isDiscoverable = false
33
34 /// Clocks out; set by the app at launch.
35 @MainActor public static var handler: (@MainActor @Sendable () async -> Void)?
36
37 public init() {}
38
39 public func perform() async throws -> some IntentResult {
40 let handler = await MainActor.run { Self.handler }
41 await handler?()
42 return .result()
43 }
44}
45
46/// OrgApp's App Intents, for the app and the widget extension to include.
47public struct OrgAppIntents: AppIntentsPackage {}
48#endif
Sources/OrgstarMobile/LiveClock.swift added +47
@@ -0,0 +1,47 @@
1#if os(iOS)
2import ActivityKit
3import AppIntents
4import OrgApp
5
6/// The running clock's Live Activity: one while a clock runs, started when it starts and
7/// ended when it stops, however that happens.
8@MainActor
9enum LiveClock {
10 nonisolated private static func isLive(_ activity: Activity<ClockActivityAttributes>) -> Bool {
11 activity.activityState == .active || activity.activityState == .stale
12 }
13
14 nonisolated private static func shows(_ activity: Activity<ClockActivityAttributes>, _ running: ClockModel.Running) -> Bool {
15 activity.attributes.path == running.path && activity.attributes.start == running.start
16 }
17
18 /// Ends activities of other clocks; starts one for `running` unless it has one.
19 nonisolated static func sync(_ running: ClockModel.Running?) async {
20 let activities = Activity<ClockActivityAttributes>.activities.filter(isLive)
21 for activity in activities where running.map({ !shows(activity, $0) }) ?? true {
22 await activity.end(nil, dismissalPolicy: .immediate)
23 }
24 guard let running, !activities.contains(where: { shows($0, running) }), ActivityAuthorizationInfo().areActivitiesEnabled else { return }
25 let state = ClockActivityAttributes.ContentState(heading: running.heading, startedAt: running.startedAt)
26 _ = try? Activity.request(
27 attributes: ClockActivityAttributes(path: running.path, start: running.start), content: ActivityContent(state: state, staleDate: nil)
28 )
29 }
30
31 /// The Clock Out button's work, in the app's process.
32 static func install() {
33 ClockOutIntent.handler = {
34 let model = MobileModel.shared
35 let path = model.clock.running?.path
36 _ = model.clock.clockOut(model.session)
37 if let path, model.session.dirtyPaths.contains(path) { _ = model.session.save(path) }
38 await sync(model.clock.running)
39 }
40 }
41}
42
43/// The App Intents OrgApp declares.
44struct OrgstarIntents: AppIntentsPackage {
45 static var includedPackages: [any AppIntentsPackage.Type] { [OrgAppIntents.self] }
46}
47#endif
Sources/OrgstarMobile/MobileModel.swift +18
@@ -7,6 +7,7 @@ import OrgIndex
7import OrgPresentation 7import OrgPresentation
8import OrgWorkspace 8import OrgWorkspace
9import UIKit 9import UIKit
10import WidgetKit
10 11
11/// The app's models, and the settings from the configuration folder: a folder you choose 12/// The app's models, and the settings from the configuration folder: a folder you choose
12/// (a synced copy of the Mac's `~/.config/orgstar`) with `config.toml` and `capture.toml`. 13/// (a synced copy of the Mac's `~/.config/orgstar`) with `config.toml` and `capture.toml`.
@@ -209,5 +210,22 @@ final class MobileModel {
209 let reminders = await agenda.reminders(open: session.openTexts, lead: OrgPreferences.reminderLead()) 210 let reminders = await agenda.reminders(open: session.openTexts, lead: OrgPreferences.reminderLead())
210 await scheduler.reconcile(reminders) 211 await scheduler.reconcile(reminders)
211 } 212 }
213
214 /// The widgets' agenda: today and the next days in the app group's container, with the
215 /// widgets reloaded when it changed.
216 func updateWidgets() async {
217 guard let workspace, let file = AgendaSnapshot.file() else { return }
218 let days = AgendaModel(workspace: workspace)
219 days.mode = .agenda(span: AgendaSnapshot.dayCount, startOffset: 0)
220 if let agenda {
221 days.includeSubfolders = agenda.includeSubfolders
222 days.options = agenda.options
223 days.calendarEvents = agenda.calendarEvents
224 days.calendarNames = agenda.calendarNames
225 }
226 await days.refresh(open: session.openTexts)
227 let snapshot = AgendaSnapshot(groups: days.groups(), today: days.today, theme: theme, generated: Date())
228 if (try? snapshot.write(to: file)) == true { WidgetCenter.shared.reloadAllTimelines() }
229 }
212} 230}
213#endif 231#endif
Sources/OrgstarMobile/OrgstarMobileApp.swift +28 −1
@@ -11,6 +11,10 @@ struct OrgstarMobileApp: App {
11 @UIApplicationDelegateAdaptor private var delegate: AppDelegate 11 @UIApplicationDelegateAdaptor private var delegate: AppDelegate
12 @State private var model = MobileModel.shared 12 @State private var model = MobileModel.shared
13 13
14 init() {
15 LiveClock.install()
16 }
17
14 var body: some Scene { 18 var body: some Scene {
15 WindowGroup { 19 WindowGroup {
16 if let workspace = model.workspace, let agenda = model.agenda { 20 if let workspace = model.workspace, let agenda = model.agenda {
@@ -197,6 +201,7 @@ struct RootView: View {
197 try? await Task.sleep(for: .seconds(1)) 201 try? await Task.sleep(for: .seconds(1))
198 guard !Task.isCancelled else { return } 202 guard !Task.isCancelled else { return }
199 await model.updateReminders() 203 await model.updateReminders()
204 await model.updateWidgets()
200 } 205 }
201 // Spotlight follows the files and saved edits. 206 // Spotlight follows the files and saved edits.
202 .task(id: SpotlightKey(files: workspace.files.count, changed: workspace.changedFile?.version, saved: session.dirtyPaths.count)) { 207 .task(id: SpotlightKey(files: workspace.files.count, changed: workspace.changedFile?.version, saved: session.dirtyPaths.count)) {
@@ -217,7 +222,16 @@ struct RootView: View {
217 Task { await workspace.refresh() } 222 Task { await workspace.refresh() }
218 } 223 }
219 .onOpenURL { url in 224 .onOpenURL { url in
220 if let request = CaptureRequest(link: url.absoluteString) { capture = request } 225 if let link = AppLink(url: url) {
226 open(link)
227 } else if let request = CaptureRequest(link: url.absoluteString) {
228 capture = request
229 }
230 }
231 // The Live Activity follows the clock, wherever it starts or stops.
232 .task { await LiveClock.sync(model.clock.running) }
233 .onChange(of: model.clock.running) {
234 Task { await LiveClock.sync(model.clock.running) }
221 } 235 }
222 } 236 }
223 237
@@ -231,6 +245,19 @@ struct RootView: View {
231 if !waiting.isEmpty { capture = waiting.removeFirst() } 245 if !waiting.isEmpty { capture = waiting.removeFirst() }
232 } 246 }
233 247
248 /// A widget's or the Live Activity's link.
249 private func open(_ link: AppLink) {
250 switch link {
251 case .agenda:
252 tab = .agenda
253 agendaPath = NavigationPath()
254 case .entry(let path, let offset):
255 model.openRequest = .offset(path, offset)
256 case .clock:
257 clock("app.clock.goto")
258 }
259 }
260
234 private func clock(_ id: String) { 261 private func clock(_ id: String) {
235 if id == "app.clock.report" { 262 if id == "app.clock.report" {
236 showingClockReport = true 263 showingClockReport = true
ios/Orgstar/Info.plist +10
@@ -24,11 +24,21 @@
24 <string>org-protocol</string> 24 <string>org-protocol</string>
25 </array> 25 </array>
26 </dict> 26 </dict>
27 <dict>
28 <key>CFBundleURLName</key>
29 <string>sh.krz.orgstar</string>
30 <key>CFBundleURLSchemes</key>
31 <array>
32 <string>orgstar</string>
33 </array>
34 </dict>
27 </array> 35 </array>
28 <key>CFBundleVersion</key> 36 <key>CFBundleVersion</key>
29 <string>$(CURRENT_PROJECT_VERSION)</string> 37 <string>$(CURRENT_PROJECT_VERSION)</string>
30 <key>NSCalendarsFullAccessUsageDescription</key> 38 <key>NSCalendarsFullAccessUsageDescription</key>
31 <string>The agenda shows your calendar events alongside your Org entries. Orgstar only reads them.</string> 39 <string>The agenda shows your calendar events alongside your Org entries. Orgstar only reads them.</string>
40 <key>NSSupportsLiveActivities</key>
41 <true/>
32 <key>UIApplicationSceneManifest</key> 42 <key>UIApplicationSceneManifest</key>
33 <dict> 43 <dict>
34 <key>UIApplicationSupportsMultipleScenes</key> 44 <key>UIApplicationSupportsMultipleScenes</key>