iOS: Quick Look previews and Spotlight !119

merged merged by cmc on 2026-10-07 00:37 UTC · krz/orgstar:ios-extensions into main

5 files changed, +169 −1

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"