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 {
1717 let agenda: AgendaModel?
1818 let scheduler = ReminderScheduler(center: SystemReminderCenter())
1919 let clock = ClockModel()
20 let spotlight = SpotlightIndexer()
2021 private(set) var configFolder: URL?
2122 private(set) var configProblems: [String] = []
2223 /// An entry to show, from a tapped reminder.
Sources/OrgstarMobile/OrgstarMobileApp.swift +14
@@ -1,4 +1,5 @@
11#if os(iOS)
2import CoreSpotlight
23import OrgApp
34import OrgCore
45import SwiftUI
@@ -79,6 +80,12 @@ struct RootView: View {
7980
8081 private var session: DocumentSession { model.session }
8182
83 private struct SpotlightKey: Equatable {
84 var files: Int
85 var changed: Int?
86 var saved: Int
87 }
88
8289 private struct ReminderKey: Equatable {
8390 var files: Int
8491 var changed: Int?
@@ -159,6 +166,13 @@ struct RootView: View {
159166 guard !Task.isCancelled else { return }
160167 await model.updateReminders()
161168 }
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 }
162176 .onChange(of: scenePhase) { _, phase in
163177 // Unsaved edits are written before the app may be stopped.
164178 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 @@
11import Foundation
22import OrgCore
3#if canImport(QuickLookUI)
34import QuickLookUI
5#else
6import QuickLook
7#endif
48
59/// Quick Look's preview of an org file: the file as the HTML export renders it.
610@objc(PreviewProvider)
scripts/build-ios-app.sh +67 −1
@@ -9,7 +9,9 @@ version="${1:-0.1.0}"
99app=".build/ios-app/Orgstar.app"
1010products=".build/ios/Build/Products/Debug-iphonesimulator"
1111
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
1315
1416rm -rf "$app"
1517mkdir -p "$app"
@@ -63,6 +65,24 @@ cat > "$app/Info.plist" <<PLIST
6365 <string>UIInterfaceOrientationLandscapeLeft</string>
6466 <string>UIInterfaceOrientationLandscapeRight</string>
6567 </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>
6686 <key>CFBundleURLTypes</key>
6787 <array>
6888 <dict>
@@ -76,5 +96,51 @@ cat > "$app/Info.plist" <<PLIST
7696</plist>
7797PLIST
7898
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
79145codesign --force --sign - "$app"
80146echo "$app"