krz/orgstar

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

Commit a7f4c9d619

a7f4c9d619da8752c532135026d3dd6ac4a979ee

parent: fb4402de47

Verified · cmc

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

iOS: share extension and Shortcuts action

Orgstar in the iOS share sheet takes a page's link and title, or text, with
a template and a note. It leaves an org-protocol capture link in the app
group's container; the app opens its capture sheet with each one when it's
next active, and keeps its template list there for the extension to offer.

Shortcuts' "Capture to Orgstar" runs on iOS as on the Mac, without the
capture sheet. Its capture code moved into OrgApp for both apps. On iOS %c is
empty, since reading the pasteboard would ask for permission on every run.

build-ios-app.sh bundles the extension, builds with the app group
entitlement and writes the App Intents metadata.

Layout: unified · split

Package.swift +4 −1
@@ -20,7 +20,8 @@ let package = Package(
20 .executable(name: "Orgstar", targets: ["Orgstar"]), 20 .executable(name: "Orgstar", targets: ["Orgstar"]),
21 .executable(name: "OrgstarMobile", targets: ["OrgstarMobile"]), 21 .executable(name: "OrgstarMobile", targets: ["OrgstarMobile"]),
22 .executable(name: "OrgstarQuickLook", targets: ["OrgstarQuickLook"]), 22 .executable(name: "OrgstarQuickLook", targets: ["OrgstarQuickLook"]),
23 .executable(name: "OrgstarSpotlight", targets: ["OrgstarSpotlight"]) 23 .executable(name: "OrgstarSpotlight", targets: ["OrgstarSpotlight"]),
24 .executable(name: "OrgstarShare", targets: ["OrgstarShare"])
24 ], 25 ],
25 dependencies: [ 26 dependencies: [
26 .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1"), 27 .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1"),
@@ -86,6 +87,8 @@ let package = Package(
86 // App extensions, bundled into Orgstar.app by scripts/build-app.sh. 87 // App extensions, bundled into Orgstar.app by scripts/build-app.sh.
87 .executableTarget(name: "OrgstarQuickLook", dependencies: ["OrgCore"], linkerSettings: extensionEntry), 88 .executableTarget(name: "OrgstarQuickLook", dependencies: ["OrgCore"], linkerSettings: extensionEntry),
88 .executableTarget(name: "OrgstarSpotlight", dependencies: ["OrgCore"], linkerSettings: extensionEntry), 89 .executableTarget(name: "OrgstarSpotlight", dependencies: ["OrgCore"], linkerSettings: extensionEntry),
90 // The iOS share extension, bundled into the iOS app by scripts/build-ios-app.sh.
91 .executableTarget(name: "OrgstarShare", dependencies: ["OrgApp"], linkerSettings: extensionEntry),
89 .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]), 92 .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]),
90 .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]), 93 .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]),
91 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]), 94 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]),
README.md +4
@@ -37,6 +37,10 @@ scripts/build-ios-app.sh # builds .build/ios-app/Orgstar.app
37xcrun simctl install booted .build/ios-app/Orgstar.app 37xcrun simctl install booted .build/ios-app/Orgstar.app
38``` 38```
39 39
40On iOS, Orgstar in the share sheet takes a page or text with a template; the
41app opens its capture sheet with it when it's next active. Shortcuts has the
42same "Capture to Orgstar" action as on the Mac.
43
40Settings live in `~/.config/orgstar/config.toml` (Orgstar ▸ Edit Config File), 44Settings live in `~/.config/orgstar/config.toml` (Orgstar ▸ Edit Config File),
41with keys in `keymap.toml` and capture templates in `capture.toml` beside it. 45with keys in `keymap.toml` and capture templates in `capture.toml` beside it.
42Settings ▸ General ▸ Import from Emacs reads an existing Emacs or Doom 46Settings ▸ General ▸ Import from Emacs reads an existing Emacs or Doom
Sources/OrgApp/CaptureInbox.swift added +57
@@ -0,0 +1,57 @@
1import Foundation
2
3/// Captures shared from other apps, waiting for the app to file them: `org-protocol` capture
4/// links in the app group's container, which the share extension writes. The app keeps its
5/// template list there for the extension to offer.
6public struct CaptureInbox: Sendable {
7 public static let group = "group.sh.krz.orgstar"
8
9 public struct Template: Codable, Sendable, Hashable {
10 public let key: String
11 public let name: String
12
13 public init(key: String, name: String) {
14 self.key = key
15 self.name = name
16 }
17 }
18
19 public let folder: URL
20
21 /// The app group's inbox; nil when the app isn't entitled to the group.
22 public init?(group: String = CaptureInbox.group) {
23 guard let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) else { return nil }
24 folder = container.appendingPathComponent("Captures")
25 }
26
27 public init(folder: URL) {
28 self.folder = folder
29 }
30
31 private var templatesFile: URL { folder.appendingPathComponent("templates.json") }
32
33 public func add(_ link: String) throws {
34 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
35 let name = String(format: "%020.6f", Date().timeIntervalSince1970) + "-" + UUID().uuidString + ".link"
36 try Data(link.utf8).write(to: folder.appendingPathComponent(name), options: .atomic)
37 }
38
39 /// The waiting links, oldest first, each removed as it's taken.
40 public func take() -> [String] {
41 let names = ((try? FileManager.default.contentsOfDirectory(atPath: folder.path)) ?? []).filter { $0.hasSuffix(".link") }.sorted()
42 return names.compactMap { name in
43 let file = folder.appendingPathComponent(name)
44 defer { try? FileManager.default.removeItem(at: file) }
45 return (try? Data(contentsOf: file)).flatMap { String(data: $0, encoding: .utf8) }
46 }
47 }
48
49 public func setTemplates(_ templates: [Template]) throws {
50 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
51 try JSONEncoder().encode(templates).write(to: templatesFile, options: .atomic)
52 }
53
54 public func templates() -> [Template] {
55 (try? JSONDecoder().decode([Template].self, from: Data(contentsOf: templatesFile))) ?? []
56 }
57}
Sources/OrgApp/CaptureModel.swift +23
@@ -148,6 +148,29 @@ struct CapturePlacement: OrgCommand {
148 } 148 }
149} 149}
150 150
151extension DocumentSession {
152 /// Shortcuts' "Capture to Orgstar": `text` as `%i` in the template keyed `key` (the first
153 /// when nil or empty), its prompts taking their defaults, filed without the capture window.
154 /// The template used.
155 public func capture(
156 _ text: String, templateKey key: String?, templates: [CaptureTemplate], workspace: WorkspaceModel, clock: ClockModel,
157 clipboard: String, userName: String
158 ) -> Result<CaptureTemplate, CaptureError> {
159 let key = key.flatMap { $0.isEmpty ? nil : $0 }
160 guard let chosen = key.map({ key in templates.first { $0.key == key } }) ?? templates.first else {
161 return .failure(CaptureError(message: "No capture template \"\(key ?? "")\""))
162 }
163 let context = CaptureContext(initial: text, clipboard: clipboard, userName: userName, allTags: (try? workspace.index.tags()) ?? [])
164 let answers = Capture.questions(chosen.template, context: context).map { _ in "" }
165 guard case .success(let filled) = Capture.fill(chosen.template, context: context, answers: answers) else {
166 return .failure(CaptureError(message: "Can't fill template \"\(chosen.key)\""))
167 }
168 return chosen.resolved(workspace: workspace, session: self, clock: clock).flatMap { resolved in
169 capture(filled.text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file)
170 }.map { _ in chosen }
171 }
172}
173
151extension CaptureTemplate { 174extension CaptureTemplate {
152 /// `id` and `clock` targets as a file and a heading. 175 /// `id` and `clock` targets as a file and a heading.
153 @MainActor 176 @MainActor
Sources/OrgCore/Links/OrgProtocol.swift +9
@@ -49,6 +49,15 @@ public enum OrgProtocol {
49 return nil 49 return nil
50 } 50 }
51 51
52 /// An `org-protocol://capture?…` link, as bookmarklets build them, with every value
53 /// percent-encoded so `+`, `&` and `=` survive `parse`.
54 public static func captureURL(template: String?, url: String?, title: String, body: String) -> String {
55 let fields: [(String, String?)] = [("template", template), ("url", url), ("title", title), ("body", body)]
56 return "org-protocol://capture?" + fields.compactMap { key, value in
57 value.map { key + "=" + ($0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? $0) }
58 }.joined(separator: "&")
59 }
60
52 /// `org-protocol-convert-query-to-plist` and `org-link-decode`: `+` is a space; a value 61 /// `org-protocol-convert-query-to-plist` and `org-link-decode`: `+` is a space; a value
53 /// ends at its second `=`. 62 /// ends at its second `=`.
54 static func query(_ info: String) -> [(String, String?)] { 63 static func query(_ info: String) -> [(String, String?)] {
Sources/Orgstar/CaptureIntent.swift +4 −16
@@ -28,26 +28,14 @@ struct CaptureIntent: AppIntent {
28 // Launched for the intent, the window sets the models up shortly. 28 // Launched for the intent, the window sets the models up shortly.
29 for _ in 0..<50 where Self.environment == nil { try? await Task.sleep(for: .milliseconds(100)) } 29 for _ in 0..<50 where Self.environment == nil { try? await Task.sleep(for: .milliseconds(100)) }
30 guard let (workspace, session, clock, commands) = Self.environment else { throw Failure(message: "Orgstar isn't ready") } 30 guard let (workspace, session, clock, commands) = Self.environment else { throw Failure(message: "Orgstar isn't ready") }
31 let templates = CaptureTemplates.load().templates 31 let placed = session.capture(
32 let key = template.flatMap { $0.isEmpty ? nil : $0 } 32 text, templateKey: template, templates: CaptureTemplates.load().templates, workspace: workspace, clock: clock,
33 guard let chosen = key.map({ key in templates.first { $0.key == key } }) ?? templates.first else { 33 clipboard: NSPasteboard.general.string(forType: .string) ?? "", userName: NSFullUserName()
34 throw Failure(message: "No capture template \"\(key ?? "")\"")
35 }
36 let context = CaptureContext(
37 initial: text, clipboard: NSPasteboard.general.string(forType: .string) ?? "", userName: NSFullUserName(),
38 allTags: (try? workspace.index.tags()) ?? []
39 ) 34 )
40 let answers = Capture.questions(chosen.template, context: context).map { _ in "" }
41 guard case .success(let filled) = Capture.fill(chosen.template, context: context, answers: answers) else {
42 throw Failure(message: "Can't fill template \"\(chosen.key)\"")
43 }
44 let placed = chosen.resolved(workspace: workspace, session: session, clock: clock).flatMap { resolved in
45 session.capture(filled.text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file)
46 }
47 switch placed { 35 switch placed {
48 case .failure(let error): 36 case .failure(let error):
49 throw Failure(message: error.message) 37 throw Failure(message: error.message)
50 case .success: 38 case .success(let chosen):
51 let message = "Captured to \(CaptureView.target(chosen))" 39 let message = "Captured to \(CaptureView.target(chosen))"
52 commands.show(message) 40 commands.show(message)
53 return .result(value: message, dialog: "\(message)") 41 return .result(value: message, dialog: "\(message)")
Sources/OrgstarMobile/CaptureIntent.swift added +48
@@ -0,0 +1,48 @@
1#if os(iOS)
2import AppIntents
3import OrgApp
4import OrgCore
5
6/// Shortcuts' "Capture to Orgstar": files text with a capture template without the capture
7/// sheet. Prompts in the template take their defaults, and `%c` is empty: reading the
8/// pasteboard would ask for permission on every run.
9struct CaptureIntent: AppIntent {
10 static let title: LocalizedStringResource = "Capture to Orgstar"
11 static let description = IntentDescription("Files text with one of your capture templates.")
12
13 @Parameter(title: "Text")
14 var text: String
15
16 @Parameter(title: "Template Key", description: "A template's key in capture.toml; the first template when empty.")
17 var template: String?
18
19 struct Failure: Error, CustomLocalizedStringResourceConvertible {
20 let message: String
21 var localizedStringResource: LocalizedStringResource { "\(message)" }
22 }
23
24 @MainActor
25 func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
26 let model = MobileModel.shared
27 guard let workspace = model.workspace else { throw Failure(message: model.failure ?? "Orgstar isn't ready") }
28 let placed = model.session.capture(
29 text, templateKey: template, templates: CaptureTemplates.load().templates, workspace: workspace, clock: model.clock,
30 clipboard: "", userName: ""
31 )
32 switch placed {
33 case .failure(let error):
34 throw Failure(message: error.message)
35 case .success(let chosen):
36 for path in model.session.dirtyPaths { _ = model.session.save(path) }
37 let message = "Captured with \(chosen.name)"
38 return .result(value: message, dialog: "\(message)")
39 }
40 }
41}
42
43struct OrgstarShortcuts: AppShortcutsProvider {
44 static var appShortcuts: [AppShortcut] {
45 AppShortcut(intent: CaptureIntent(), phrases: ["Capture to \(.applicationName)"], shortTitle: "Capture", systemImageName: "tray.and.arrow.down")
46 }
47}
48#endif
Sources/OrgstarMobile/MobileModel.swift +13 −1
@@ -11,6 +11,9 @@ import OrgWorkspace
11@MainActor 11@MainActor
12@Observable 12@Observable
13final class MobileModel { 13final class MobileModel {
14 /// One per process: Shortcuts runs the capture action without a window.
15 static let shared = MobileModel()
16
14 let workspace: WorkspaceModel? 17 let workspace: WorkspaceModel?
15 let failure: String? 18 let failure: String?
16 let session = DocumentSession() 19 let session = DocumentSession()
@@ -18,6 +21,8 @@ final class MobileModel {
18 let scheduler = ReminderScheduler(center: SystemReminderCenter()) 21 let scheduler = ReminderScheduler(center: SystemReminderCenter())
19 let clock = ClockModel() 22 let clock = ClockModel()
20 let spotlight = SpotlightIndexer() 23 let spotlight = SpotlightIndexer()
24 /// Captures from the share extension; nil without the app group.
25 let inbox = CaptureInbox()
21 private(set) var configFolder: URL? 26 private(set) var configFolder: URL?
22 private(set) var configProblems: [String] = [] 27 private(set) var configProblems: [String] = []
23 /// An entry to show, from a tapped reminder. 28 /// An entry to show, from a tapped reminder.
@@ -28,7 +33,7 @@ final class MobileModel {
28 33
29 private static let bookmarkKey = "configFolderBookmark" 34 private static let bookmarkKey = "configFolderBookmark"
30 35
31 init() { 36 private init() {
32 // Development: `ORGSTAR_CONFIG_DIR` stands in for a chosen folder. 37 // Development: `ORGSTAR_CONFIG_DIR` stands in for a chosen folder.
33 let folder = Self.resolveBookmark() ?? ProcessInfo.processInfo.environment["ORGSTAR_CONFIG_DIR"].map { URL(fileURLWithPath: $0) } 38 let folder = Self.resolveBookmark() ?? ProcessInfo.processInfo.environment["ORGSTAR_CONFIG_DIR"].map { URL(fileURLWithPath: $0) }
34 UserConfig.directoryOverride = folder 39 UserConfig.directoryOverride = folder
@@ -47,6 +52,7 @@ final class MobileModel {
47 configFolder = folder 52 configFolder = folder
48 configProblems = problems 53 configProblems = problems
49 applySettings() 54 applySettings()
55 shareTemplates()
50 watchConfig() 56 watchConfig()
51 } 57 }
52 58
@@ -69,6 +75,7 @@ final class MobileModel {
69 UserConfig.directoryOverride = configFolder 75 UserConfig.directoryOverride = configFolder
70 configProblems = Self.readConfig(configFolder) 76 configProblems = Self.readConfig(configFolder)
71 applySettings() 77 applySettings()
78 shareTemplates()
72 } 79 }
73 80
74 private func applySettings() { 81 private func applySettings() {
@@ -84,6 +91,11 @@ final class MobileModel {
84 settingsVersion += 1 91 settingsVersion += 1
85 } 92 }
86 93
94 /// The capture templates, for the share extension to offer.
95 private func shareTemplates() {
96 try? inbox?.setTemplates(CaptureTemplates.load().templates.map { CaptureInbox.Template(key: $0.key, name: $0.name) })
97 }
98
87 /// Edits to the folder's files, synced from the Mac, apply as they arrive. 99 /// Edits to the folder's files, synced from the Mac, apply as they arrive.
88 private func watchConfig() { 100 private func watchConfig() {
89 presenter?.stop() 101 presenter?.stop()
Sources/OrgstarMobile/OrgstarMobileApp.swift +25 −5
@@ -9,7 +9,7 @@ import UserNotifications
9@main 9@main
10struct OrgstarMobileApp: App { 10struct OrgstarMobileApp: App {
11 @UIApplicationDelegateAdaptor private var delegate: AppDelegate 11 @UIApplicationDelegateAdaptor private var delegate: AppDelegate
12 @State private var model = MobileModel() 12 @State private var model = MobileModel.shared
13 13
14 var body: some Scene { 14 var body: some Scene {
15 WindowGroup { 15 WindowGroup {
@@ -66,11 +66,21 @@ struct CaptureRequest: Identifiable {
66 var properties: [String: String] = [:] 66 var properties: [String: String] = [:]
67} 67}
68 68
69extension CaptureRequest {
70 /// An `org-protocol://capture` link's request.
71 init?(link: String) {
72 guard case .capture(let template, let properties)? = OrgProtocol.parse(link) else { return nil }
73 self.init(template: template, properties: properties)
74 }
75}
76
69struct RootView: View { 77struct RootView: View {
70 let model: MobileModel 78 let model: MobileModel
71 let workspace: WorkspaceModel 79 let workspace: WorkspaceModel
72 let agenda: AgendaModel 80 let agenda: AgendaModel
73 @State private var capture: CaptureRequest? 81 @State private var capture: CaptureRequest?
82 /// Captures from the share extension after the one showing.
83 @State private var waiting: [CaptureRequest] = []
74 @State private var tab = AppTab.agenda 84 @State private var tab = AppTab.agenda
75 @State private var agendaPath = NavigationPath() 85 @State private var agendaPath = NavigationPath()
76 @State private var hour = 0 86 @State private var hour = 0
@@ -143,10 +153,11 @@ struct RootView: View {
143 // An open file takes in what changed on disk. 153 // An open file takes in what changed on disk.
144 if let path = workspace.changedFile?.path { session.fileChanged(path) } 154 if let path = workspace.changedFile?.path { session.fileChanged(path) }
145 } 155 }
146 .sheet(item: $capture) { request in 156 .sheet(item: $capture, onDismiss: showWaiting) { request in
147 CaptureScreen(workspace: workspace, session: session, request: request) 157 CaptureScreen(workspace: workspace, session: session, request: request)
148 } 158 }
149 .task { 159 .task {
160 takeShared()
150 // Development: `ORGSTAR_ROOT` adds a folder at launch, as on the Mac. 161 // Development: `ORGSTAR_ROOT` adds a folder at launch, as on the Mac.
151 if let root = ProcessInfo.processInfo.environment["ORGSTAR_ROOT"] { try? await workspace.addRoot(URL(fileURLWithPath: root)) } 162 if let root = ProcessInfo.processInfo.environment["ORGSTAR_ROOT"] { try? await workspace.addRoot(URL(fileURLWithPath: root)) }
152 workspace.startWatching() 163 workspace.startWatching()
@@ -180,16 +191,25 @@ struct RootView: View {
180 } 191 }
181 // Files and settings synced while away; not every sync tool tells file presenters. 192 // Files and settings synced while away; not every sync tool tells file presenters.
182 guard phase == .active else { return } 193 guard phase == .active else { return }
194 takeShared()
183 model.reloadConfig() 195 model.reloadConfig()
184 Task { await workspace.refresh() } 196 Task { await workspace.refresh() }
185 } 197 }
186 .onOpenURL { url in 198 .onOpenURL { url in
187 if case .capture(let template, let properties)? = OrgProtocol.parse(url.absoluteString) { 199 if let request = CaptureRequest(link: url.absoluteString) { capture = request }
188 capture = CaptureRequest(template: template, properties: properties)
189 }
190 } 200 }
191 } 201 }
192 202
203 /// What the share extension left, one capture sheet at a time.
204 private func takeShared() {
205 waiting += (model.inbox?.take() ?? []).compactMap(CaptureRequest.init(link:))
206 if capture == nil { showWaiting() }
207 }
208
209 private func showWaiting() {
210 if !waiting.isEmpty { capture = waiting.removeFirst() }
211 }
212
193 private func clock(_ id: String) { 213 private func clock(_ id: String) {
194 switch id { 214 switch id {
195 case "app.clock.out": message = model.clock.clockOut(session) 215 case "app.clock.out": message = model.clock.clockOut(session)
Sources/OrgstarShare/ShareViewController.swift added +112
@@ -0,0 +1,112 @@
1#if os(iOS)
2import OrgApp
3import OrgCore
4import SwiftUI
5import UIKit
6import UniformTypeIdentifiers
7
8/// The share sheet's Orgstar: the shared page or text with a template and a note, left for the
9/// app, which opens its capture sheet with them the next time it's active.
10@objc(ShareViewController)
11final class ShareViewController: UIViewController {
12 override func viewDidLoad() {
13 super.viewDidLoad()
14 let items = extensionContext?.inputItems as? [NSExtensionItem] ?? []
15 Task { @MainActor in
16 let shared = await Shared.load(items)
17 let inbox = CaptureInbox()
18 let form = ShareForm(shared: shared, templates: inbox?.templates() ?? [], available: inbox != nil) { [weak self] link in
19 if let link { try? inbox?.add(link) }
20 self?.extensionContext?.completeRequest(returningItems: nil)
21 }
22 let host = UIHostingController(rootView: form)
23 addChild(host)
24 host.view.frame = view.bounds
25 host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
26 view.addSubview(host.view)
27 host.didMove(toParent: self)
28 }
29 }
30}
31
32/// What the other app shared: a page's address and title, or text.
33struct Shared {
34 var url: URL?
35 var title = ""
36 var text = ""
37
38 @MainActor
39 static func load(_ items: [NSExtensionItem]) async -> Shared {
40 var shared = Shared()
41 for item in items {
42 if shared.title.isEmpty, let title = item.attributedTitle?.string ?? item.attributedContentText?.string { shared.title = title }
43 for provider in item.attachments ?? [] {
44 if shared.url == nil, provider.hasItemConformingToTypeIdentifier(UTType.url.identifier),
45 let url = try? await provider.loadItem(forTypeIdentifier: UTType.url.identifier) as? URL {
46 shared.url = url
47 } else if shared.text.isEmpty, provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier),
48 let text = try? await provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) as? String {
49 shared.text = text
50 }
51 }
52 }
53 // Text shared on its own arrives as the item's content too.
54 if shared.url == nil, shared.text == shared.title { shared.title = "" }
55 return shared
56 }
57}
58
59struct ShareForm: View {
60 let shared: Shared
61 let templates: [CaptureInbox.Template]
62 let available: Bool
63 let finish: (String?) -> Void
64 @State private var title = ""
65 @State private var note = ""
66 @State private var template: String?
67
68 var body: some View {
69 NavigationStack {
70 Form {
71 if !available {
72 Text("Orgstar's shared folder isn't available.").foregroundStyle(.red)
73 }
74 if !templates.isEmpty {
75 Picker("Template", selection: $template) {
76 ForEach(templates, id: \.key) { Text($0.name).tag(Optional($0.key)) }
77 }
78 }
79 if let url = shared.url {
80 Section("Link") {
81 TextField("Title", text: $title)
82 Text(url.absoluteString).font(.footnote).foregroundStyle(.secondary).lineLimit(2)
83 }
84 }
85 Section("Text") {
86 TextEditor(text: $note).frame(minHeight: 120)
87 }
88 Section {
89 } footer: {
90 Text("Orgstar files it with the template when you next open it.")
91 }
92 }
93 .navigationTitle("Orgstar")
94 .navigationBarTitleDisplayMode(.inline)
95 .toolbar {
96 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { finish(nil) } }
97 ToolbarItem(placement: .confirmationAction) {
98 Button("Capture") {
99 finish(OrgProtocol.captureURL(template: template, url: shared.url?.absoluteString, title: title, body: note))
100 }
101 .disabled(!available)
102 }
103 }
104 }
105 .onAppear {
106 title = shared.title
107 note = shared.text
108 template = templates.first?.key
109 }
110 }
111}
112#endif
Sources/OrgstarShare/main.swift added +2
@@ -0,0 +1,2 @@
1// The extension starts in NSExtensionMain (linker entry point); this file only makes the
2// target an executable.
Tests/OrgAppTests/CaptureInboxTests.swift added +49
@@ -0,0 +1,49 @@
1import Foundation
2import OrgCore
3import OrgDocument
4import Testing
5@testable import OrgApp
6
7struct CaptureInboxTests {
8 @Test func takesLinksOldestFirstOnce() throws {
9 let folder = try Folder()
10 let inbox = CaptureInbox(folder: folder.url.appendingPathComponent("Captures"))
11 #expect(inbox.take() == [])
12 try inbox.add("org-protocol://capture?body=one")
13 try inbox.add("org-protocol://capture?body=two")
14 #expect(inbox.take() == ["org-protocol://capture?body=one", "org-protocol://capture?body=two"])
15 #expect(inbox.take() == [])
16 }
17
18 @Test func keepsTheTemplateList() throws {
19 let folder = try Folder()
20 let inbox = CaptureInbox(folder: folder.url.appendingPathComponent("Captures"))
21 #expect(inbox.templates() == [])
22 let templates = [CaptureInbox.Template(key: "t", name: "Task"), CaptureInbox.Template(key: "w", name: "Web page")]
23 try inbox.setTemplates(templates)
24 #expect(inbox.templates() == templates)
25 }
26}
27
28@MainActor
29struct CaptureWithoutWindowTests {
30 @Test func filesWithTheKeyedTemplateAndDefaultAnswers() async throws {
31 let notes = try Folder()
32 let todo = try notes.write("todo.org", "* Inbox\n")
33 let workspace = try WorkspaceModel(directory: nil)
34 try await workspace.addRoot(notes.url)
35 let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
36 let clock = ClockModel(file: notes.url.appendingPathComponent("clock.json"))
37 let templates = [
38 CaptureTemplates.defaults[0],
39 CaptureTemplate(key: "x", name: "Errand", target: .headline(file: "todo.org", headline: "Inbox"), template: "* TODO %^{What|Buy milk}\n%i"),
40 ]
41 let chosen = session.capture("from the share sheet", templateKey: "x", templates: templates, workspace: workspace, clock: clock, clipboard: "", userName: "")
42 #expect((try? chosen.get())?.key == "x")
43 #expect(try String(contentsOf: todo, encoding: .utf8) == "* Inbox\n** TODO Buy milk\nfrom the share sheet\n")
44
45 let missing = session.capture("x", templateKey: "nope", templates: templates, workspace: workspace, clock: clock, clipboard: "", userName: "")
46 guard case .failure(let error) = missing else { Issue.record("filed with a missing template"); return }
47 #expect(error.message == "No capture template \"nope\"")
48 }
49}
Tests/OrgCoreTests/OrgProtocolTests.swift +11
@@ -60,4 +60,15 @@ struct OrgProtocolTests {
60 #expect(ours == result.text, "\(url)\nours: \(ours.debugDescription)\nemacs: \(result.text.debugDescription) \(result.error)") 60 #expect(ours == result.text, "\(url)\nours: \(ours.debugDescription)\nemacs: \(result.text.debugDescription) \(result.error)")
61 } 61 }
62 } 62 }
63
64 @Test func captureURLRoundTrips() {
65 let url = OrgProtocol.captureURL(template: "w", url: "https://example.com/a?b=1&c=2+3", title: "Tea & cake = 100% café", body: "line one\nline+two")
66 #expect(OrgProtocol.parse(url) == .capture(template: "w", properties: [
67 "link": "https://example.com/a?b=1&c=2+3", "type": "https",
68 "description": "Tea & cake = 100% café", "initial": "line one\nline+two",
69 "annotation": "[[https://example.com/a?b=1&c=2+3][Tea & cake = 100% café]]",
70 ]))
71 #expect(OrgProtocol.parse(OrgProtocol.captureURL(template: nil, url: nil, title: "", body: "just text"))
72 == .capture(template: nil, properties: ["description": "", "initial": "just text", "annotation": ""]))
73 }
63} 74}
scripts/OrgstarMobile.entitlements added +8
@@ -0,0 +1,8 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>com.apple.security.application-groups</key>
6 <array><string>group.sh.krz.orgstar</string></array>
7</dict>
8</plist>
scripts/build-ios-app.sh +40 −20
@@ -9,8 +9,12 @@ 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
12for scheme in OrgstarMobile OrgstarQuickLook; do 12# The entitlements (the app group the share extension leaves captures in) go where the
13 xcodebuild -scheme "$scheme" -destination 'generic/platform=iOS Simulator' -derivedDataPath .build/ios build -quiet 13# Simulator reads them, into the binaries.
14entitlements=scripts/OrgstarMobile.entitlements
15for scheme in OrgstarMobile OrgstarQuickLook OrgstarShare; do
16 xcodebuild -scheme "$scheme" -destination 'generic/platform=iOS Simulator' -derivedDataPath .build/ios build -quiet \
17 CODE_SIGN_ENTITLEMENTS="$PWD/$entitlements"
14done 18done
15 19
16rm -rf "$app" 20rm -rf "$app"
@@ -96,23 +100,35 @@ cat > "$app/Info.plist" <<PLIST
96</plist> 100</plist>
97PLIST 101PLIST
98 102
99# Quick Look previews of org files, in Files and wherever else Quick Look shows them. 103# App Intents metadata for Shortcuts, from the constant values the build extracted; building
100appex="$app/PlugIns/OrgstarQuickLook.appex" 104# a package doesn't run this step itself.
101mkdir -p "$appex" 105toolchain="$(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain"
102cp "$products/OrgstarQuickLook" "$appex/OrgstarQuickLook" 106objects=".build/ios/Build/Intermediates.noindex/Orgstar.build/Debug-iphonesimulator/OrgstarMobile-p.build/Objects-normal/arm64"
103cat > "$appex/Info.plist" <<PLIST 107work="$(mktemp -d)"
108ls "$PWD/$objects"/*.swiftconstvalues > "$work/constvalues.txt"
109"$toolchain/usr/bin/appintentsmetadataprocessor" --toolchain-dir "$toolchain" --module-name OrgstarMobile \
110 --sdk-root "$(xcrun --sdk iphonesimulator --show-sdk-path)" --xcode-version "$(xcodebuild -version | awk '/Build version/ {print $3}')" \
111 --platform-family iOS --deployment-target 26.0 --bundle-identifier sh.krz.orgstar \
112 --target-triple arm64-apple-ios26.0-simulator --binary-file "$app/OrgstarMobile" --source-file-list "$objects/OrgstarMobile.SwiftFileList" \
113 --swift-const-vals-list "$work/constvalues.txt" --output "$app" --force > /dev/null 2>&1
114
115appex() { # name, bundle id suffix, extension point, principal class, attributes, entitlements
116 dir="$app/PlugIns/$1.appex"
117 mkdir -p "$dir"
118 cp "$products/$1" "$dir/$1"
119 cat > "$dir/Info.plist" <<PLIST
104<?xml version="1.0" encoding="UTF-8"?> 120<?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"> 121<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
106<plist version="1.0"> 122<plist version="1.0">
107<dict> 123<dict>
108 <key>CFBundleIdentifier</key> 124 <key>CFBundleIdentifier</key>
109 <string>sh.krz.orgstar.quicklook</string> 125 <string>sh.krz.orgstar.$2</string>
110 <key>CFBundleName</key> 126 <key>CFBundleName</key>
111 <string>OrgstarQuickLook</string> 127 <string>$1</string>
112 <key>CFBundleDisplayName</key> 128 <key>CFBundleDisplayName</key>
113 <string>Orgstar</string> 129 <string>Orgstar</string>
114 <key>CFBundleExecutable</key> 130 <key>CFBundleExecutable</key>
115 <string>OrgstarQuickLook</string> 131 <string>$1</string>
116 <key>CFBundlePackageType</key> 132 <key>CFBundlePackageType</key>
117 <string>XPC!</string> 133 <string>XPC!</string>
118 <key>CFBundleShortVersionString</key> 134 <key>CFBundleShortVersionString</key>
@@ -126,21 +142,25 @@ cat > "$appex/Info.plist" <<PLIST
126 <key>NSExtension</key> 142 <key>NSExtension</key>
127 <dict> 143 <dict>
128 <key>NSExtensionPointIdentifier</key> 144 <key>NSExtensionPointIdentifier</key>
129 <string>com.apple.quicklook.preview</string> 145 <string>$3</string>
130 <key>NSExtensionPrincipalClass</key> 146 <key>NSExtensionPrincipalClass</key>
131 <string>PreviewProvider</string> 147 <string>$4</string>
132 <key>NSExtensionAttributes</key> 148 <key>NSExtensionAttributes</key>
133 <dict> 149 <dict>$5</dict>
134 <key>QLSupportedContentTypes</key>
135 <array><string>org.orgmode.org</string></array>
136 <key>QLIsDataBasedPreview</key>
137 <true/>
138 </dict>
139 </dict> 150 </dict>
140</dict> 151</dict>
141</plist> 152</plist>
142PLIST 153PLIST
143codesign --force --sign - "$appex" 154 codesign --force --sign - ${6:+--entitlements "$6"} "$dir"
155}
156# Quick Look previews of org files, in Files and wherever else Quick Look shows them.
157appex OrgstarQuickLook quicklook com.apple.quicklook.preview PreviewProvider \
158 "<key>QLSupportedContentTypes</key><array><string>org.orgmode.org</string></array><key>QLIsDataBasedPreview</key><true/>"
159# Capture from the share sheet: a page's link or text.
160appex OrgstarShare share com.apple.share-services ShareViewController \
161 "<key>NSExtensionActivationRule</key><dict><key>NSExtensionActivationSupportsWebURLWithMaxCount</key><integer>1</integer><key>NSExtensionActivationSupportsText</key><true/></dict>" \
162 "$entitlements"
144 163
145codesign --force --sign - "$app" 164codesign --force --sign - --entitlements "$entitlements" "$app"
165rm -rf "$work"
146echo "$app" 166echo "$app"