krz/orgstar

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

Commit fb4402de47

fb4402de470e935f9add3b08fd338ec154a65045

parent: 1adb6b1169

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-07 00:37 UTC

iOS: Quick Look previews and Spotlight

The Quick Look extension builds for iOS too and is bundled into the iOS app:
Files and other Quick Look hosts show org files as the HTML export renders
them. The iOS app declares org.orgmode.org as public.text, because iOS
previews plain text itself and never asks the extension.

The workspace's org files go into Spotlight from the app (iOS has no importer
extensions) with the Mac importer's attributes; a file is indexed again when
it changes, and all of them when the system has dropped the index. Opening a
result opens the file.

Layout: unified · split

Sources/OrgstarMobile/MobileModel.swift +1
@@ -17,6 +17,7 @@ final class MobileModel {
17 let agenda: AgendaModel? 17 let agenda: AgendaModel?
18 let scheduler = ReminderScheduler(center: SystemReminderCenter()) 18 let scheduler = ReminderScheduler(center: SystemReminderCenter())
19 let clock = ClockModel() 19 let clock = ClockModel()
20 let spotlight = SpotlightIndexer()
20 private(set) var configFolder: URL? 21 private(set) var configFolder: URL?
21 private(set) var configProblems: [String] = [] 22 private(set) var configProblems: [String] = []
22 /// An entry to show, from a tapped reminder. 23 /// An entry to show, from a tapped reminder.
Sources/OrgstarMobile/OrgstarMobileApp.swift +14
@@ -1,4 +1,5 @@
1#if os(iOS) 1#if os(iOS)
2import CoreSpotlight
2import OrgApp 3import OrgApp
3import OrgCore 4import OrgCore
4import SwiftUI 5import SwiftUI
@@ -79,6 +80,12 @@ struct RootView: View {
79 80
80 private var session: DocumentSession { model.session } 81 private var session: DocumentSession { model.session }
81 82
83 private struct SpotlightKey: Equatable {
84 var files: Int
85 var changed: Int?
86 var saved: Int
87 }
88
82 private struct ReminderKey: Equatable { 89 private struct ReminderKey: Equatable {
83 var files: Int 90 var files: Int
84 var changed: Int? 91 var changed: Int?
@@ -159,6 +166,13 @@ struct RootView: View {
159 guard !Task.isCancelled else { return } 166 guard !Task.isCancelled else { return }
160 await model.updateReminders() 167 await model.updateReminders()
161 } 168 }
169 // Spotlight follows the files and saved edits.
170 .task(id: SpotlightKey(files: workspace.files.count, changed: workspace.changedFile?.version, saved: session.dirtyPaths.count)) {
171 await model.spotlight.update(workspace.files)
172 }
173 .onContinueUserActivity(CSSearchableItemActionType) { activity in
174 if let path = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String { model.openRequest = .file(path) }
175 }
162 .onChange(of: scenePhase) { _, phase in 176 .onChange(of: scenePhase) { _, phase in
163 // Unsaved edits are written before the app may be stopped. 177 // Unsaved edits are written before the app may be stopped.
164 if phase == .background { 178 if phase == .background {
Sources/OrgstarMobile/SpotlightIndexer.swift added +83
@@ -0,0 +1,83 @@
1#if os(iOS)
2import CoreSpotlight
3import Foundation
4import OrgApp
5import OrgCore
6import UniformTypeIdentifiers
7
8/// The workspace's org files in Spotlight with what the Mac's importer gives it: the text,
9/// title, author, description, tags and headings. A file is indexed again when it changes.
10@MainActor
11final class SpotlightIndexer {
12 private struct State: Codable {
13 /// Also kept as the index's client state; the system deleting the index (as it does
14 /// when the app is installed again) takes it away, and every file is indexed again.
15 var generation = UUID()
16 /// Modification dates of the files as indexed.
17 var stamps: [String: Date] = [:]
18 }
19
20 private let index = CSSearchableIndex(name: "files")
21 private let stateFile = WorkspaceModel.defaultDirectory.appendingPathComponent("spotlight.json")
22 private var state: State
23 /// Files to index once the running update finishes; a batch can't start while one is open.
24 private var waiting: [FileEntry]?
25 private var running = false
26
27 init() {
28 state = (try? JSONDecoder().decode(State.self, from: Data(contentsOf: stateFile))) ?? State()
29 }
30
31 func update(_ files: [FileEntry]) async {
32 guard !running else { return waiting = files }
33 running = true
34 defer { running = false }
35 var next: [FileEntry]? = files
36 while let files = next {
37 await index(files)
38 next = waiting
39 waiting = nil
40 }
41 }
42
43 private func index(_ files: [FileEntry]) async {
44 let generation = Data(state.generation.uuidString.utf8)
45 let stamps = (try? await index.fetchLastClientState()) == generation ? state.stamps : [:]
46 var current: [String: Date] = [:]
47 var items: [CSSearchableItem] = []
48 for file in files where file.kind == .org {
49 guard let date = (try? FileManager.default.attributesOfItem(atPath: file.path))?[.modificationDate] as? Date else { continue }
50 if stamps[file.path] == date {
51 current[file.path] = date
52 } else if let text = try? String(contentsOfFile: file.path, encoding: .utf8) {
53 items.append(Self.item(file, text: text))
54 current[file.path] = date
55 }
56 }
57 let gone = stamps.keys.filter { current[$0] == nil }
58 guard !items.isEmpty || !gone.isEmpty else { return }
59 index.beginBatch()
60 if !items.isEmpty { index.indexSearchableItems(items, completionHandler: nil) }
61 if !gone.isEmpty { index.deleteSearchableItems(withIdentifiers: Array(gone), completionHandler: nil) }
62 do {
63 try await index.endBatch(withClientState: generation)
64 } catch {
65 return
66 }
67 state.stamps = current
68 try? JSONEncoder().encode(state).write(to: stateFile, options: .atomic)
69 }
70
71 private static func item(_ file: FileEntry, text: String) -> CSSearchableItem {
72 let summary = FileSummary(text)
73 let attributes = CSSearchableItemAttributeSet(contentType: .text)
74 attributes.title = summary.title ?? (file.name as NSString).deletingPathExtension
75 attributes.displayName = attributes.title
76 attributes.contentDescription = summary.description ?? file.relativePath
77 attributes.textContent = text
78 if let author = summary.author { attributes.authorNames = [author] }
79 attributes.keywords = summary.tags + summary.headings
80 return CSSearchableItem(uniqueIdentifier: file.path, domainIdentifier: "files", attributeSet: attributes)
81 }
82}
83#endif
Sources/OrgstarQuickLook/PreviewProvider.swift +4
@@ -1,6 +1,10 @@
1import Foundation 1import Foundation
2import OrgCore 2import OrgCore
3#if canImport(QuickLookUI)
3import QuickLookUI 4import QuickLookUI
5#else
6import QuickLook
7#endif
4 8
5/// Quick Look's preview of an org file: the file as the HTML export renders it. 9/// Quick Look's preview of an org file: the file as the HTML export renders it.
6@objc(PreviewProvider) 10@objc(PreviewProvider)
scripts/build-ios-app.sh +67 −1
@@ -9,7 +9,9 @@ version="${1:-0.1.0}"
9app=".build/ios-app/Orgstar.app" 9app=".build/ios-app/Orgstar.app"
10products=".build/ios/Build/Products/Debug-iphonesimulator" 10products=".build/ios/Build/Products/Debug-iphonesimulator"
11 11
12xcodebuild -scheme OrgstarMobile -destination 'generic/platform=iOS Simulator' -derivedDataPath .build/ios build -quiet 12for scheme in OrgstarMobile OrgstarQuickLook; do
13 xcodebuild -scheme "$scheme" -destination 'generic/platform=iOS Simulator' -derivedDataPath .build/ios build -quiet
14done
13 15
14rm -rf "$app" 16rm -rf "$app"
15mkdir -p "$app" 17mkdir -p "$app"
@@ -63,6 +65,24 @@ cat > "$app/Info.plist" <<PLIST
63 <string>UIInterfaceOrientationLandscapeLeft</string> 65 <string>UIInterfaceOrientationLandscapeLeft</string>
64 <string>UIInterfaceOrientationLandscapeRight</string> 66 <string>UIInterfaceOrientationLandscapeRight</string>
65 </array> 67 </array>
68 <!-- public.text, not public.plain-text as on the Mac: iOS previews plain text itself and
69 never asks the Quick Look extension. -->
70 <key>UTImportedTypeDeclarations</key>
71 <array>
72 <dict>
73 <key>UTTypeIdentifier</key>
74 <string>org.orgmode.org</string>
75 <key>UTTypeDescription</key>
76 <string>Org document</string>
77 <key>UTTypeConformsTo</key>
78 <array><string>public.text</string></array>
79 <key>UTTypeTagSpecification</key>
80 <dict>
81 <key>public.filename-extension</key>
82 <array><string>org</string><string>org_archive</string></array>
83 </dict>
84 </dict>
85 </array>
66 <key>CFBundleURLTypes</key> 86 <key>CFBundleURLTypes</key>
67 <array> 87 <array>
68 <dict> 88 <dict>
@@ -76,5 +96,51 @@ cat > "$app/Info.plist" <<PLIST
76</plist> 96</plist>
77PLIST 97PLIST
78 98
99# Quick Look previews of org files, in Files and wherever else Quick Look shows them.
100appex="$app/PlugIns/OrgstarQuickLook.appex"
101mkdir -p "$appex"
102cp "$products/OrgstarQuickLook" "$appex/OrgstarQuickLook"
103cat > "$appex/Info.plist" <<PLIST
104<?xml version="1.0" encoding="UTF-8"?>
105<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
106<plist version="1.0">
107<dict>
108 <key>CFBundleIdentifier</key>
109 <string>sh.krz.orgstar.quicklook</string>
110 <key>CFBundleName</key>
111 <string>OrgstarQuickLook</string>
112 <key>CFBundleDisplayName</key>
113 <string>Orgstar</string>
114 <key>CFBundleExecutable</key>
115 <string>OrgstarQuickLook</string>
116 <key>CFBundlePackageType</key>
117 <string>XPC!</string>
118 <key>CFBundleShortVersionString</key>
119 <string>${version}</string>
120 <key>CFBundleVersion</key>
121 <string>${version}</string>
122 <key>CFBundleSupportedPlatforms</key>
123 <array><string>iPhoneSimulator</string></array>
124 <key>MinimumOSVersion</key>
125 <string>26.0</string>
126 <key>NSExtension</key>
127 <dict>
128 <key>NSExtensionPointIdentifier</key>
129 <string>com.apple.quicklook.preview</string>
130 <key>NSExtensionPrincipalClass</key>
131 <string>PreviewProvider</string>
132 <key>NSExtensionAttributes</key>
133 <dict>
134 <key>QLSupportedContentTypes</key>
135 <array><string>org.orgmode.org</string></array>
136 <key>QLIsDataBasedPreview</key>
137 <true/>
138 </dict>
139 </dict>
140</dict>
141</plist>
142PLIST
143codesign --force --sign - "$appex"
144
79codesign --force --sign - "$app" 145codesign --force --sign - "$app"
80echo "$app" 146echo "$app"