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(
2020 .executable(name: "Orgstar", targets: ["Orgstar"]),
2121 .executable(name: "OrgstarMobile", targets: ["OrgstarMobile"]),
2222 .executable(name: "OrgstarQuickLook", targets: ["OrgstarQuickLook"]),
23 .executable(name: "OrgstarSpotlight", targets: ["OrgstarSpotlight"])
23 .executable(name: "OrgstarSpotlight", targets: ["OrgstarSpotlight"]),
24 .executable(name: "OrgstarShare", targets: ["OrgstarShare"])
2425 ],
2526 dependencies: [
2627 .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1"),
@@ -86,6 +87,8 @@ let package = Package(
8687 // App extensions, bundled into Orgstar.app by scripts/build-app.sh.
8788 .executableTarget(name: "OrgstarQuickLook", dependencies: ["OrgCore"], linkerSettings: extensionEntry),
8889 .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),
8992 .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]),
9093 .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]),
9194 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]),
README.md +4
@@ -37,6 +37,10 @@ scripts/build-ios-app.sh # builds .build/ios-app/Orgstar.app
3737xcrun simctl install booted .build/ios-app/Orgstar.app
3838```
3939
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
4044Settings live in `~/.config/orgstar/config.toml` (Orgstar ▸ Edit Config File),
4145with keys in `keymap.toml` and capture templates in `capture.toml` beside it.
4246Settings ▸ 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 {
148148 }
149149}
150150
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
151174extension CaptureTemplate {
152175 /// `id` and `clock` targets as a file and a heading.
153176 @MainActor
Sources/OrgCore/Links/OrgProtocol.swift +9
@@ -49,6 +49,15 @@ public enum OrgProtocol {
4949 return nil
5050 }
5151
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
5261 /// `org-protocol-convert-query-to-plist` and `org-link-decode`: `+` is a space; a value
5362 /// ends at its second `=`.
5463 static func query(_ info: String) -> [(String, String?)] {
Sources/Orgstar/CaptureIntent.swift +4 −16
@@ -28,26 +28,14 @@ struct CaptureIntent: AppIntent {
2828 // Launched for the intent, the window sets the models up shortly.
2929 for _ in 0..<50 where Self.environment == nil { try? await Task.sleep(for: .milliseconds(100)) }
3030 guard let (workspace, session, clock, commands) = Self.environment else { throw Failure(message: "Orgstar isn't ready") }
31 let templates = CaptureTemplates.load().templates
32 let key = template.flatMap { $0.isEmpty ? nil : $0 }
33 guard let chosen = key.map({ key in templates.first { $0.key == key } }) ?? templates.first else {
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()) ?? []
31 let placed = session.capture(
32 text, templateKey: template, templates: CaptureTemplates.load().templates, workspace: workspace, clock: clock,
33 clipboard: NSPasteboard.general.string(forType: .string) ?? "", userName: NSFullUserName()
3934 )
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 }
4735 switch placed {
4836 case .failure(let error):
4937 throw Failure(message: error.message)
50 case .success:
38 case .success(let chosen):
5139 let message = "Captured to \(CaptureView.target(chosen))"
5240 commands.show(message)
5341 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
1111@MainActor
1212@Observable
1313final class MobileModel {
14 /// One per process: Shortcuts runs the capture action without a window.
15 static let shared = MobileModel()
16
1417 let workspace: WorkspaceModel?
1518 let failure: String?
1619 let session = DocumentSession()
@@ -18,6 +21,8 @@ final class MobileModel {
1821 let scheduler = ReminderScheduler(center: SystemReminderCenter())
1922 let clock = ClockModel()
2023 let spotlight = SpotlightIndexer()
24 /// Captures from the share extension; nil without the app group.
25 let inbox = CaptureInbox()
2126 private(set) var configFolder: URL?
2227 private(set) var configProblems: [String] = []
2328 /// An entry to show, from a tapped reminder.
@@ -28,7 +33,7 @@ final class MobileModel {
2833
2934 private static let bookmarkKey = "configFolderBookmark"
3035
31 init() {
36 private init() {
3237 // Development: `ORGSTAR_CONFIG_DIR` stands in for a chosen folder.
3338 let folder = Self.resolveBookmark() ?? ProcessInfo.processInfo.environment["ORGSTAR_CONFIG_DIR"].map { URL(fileURLWithPath: $0) }
3439 UserConfig.directoryOverride = folder
@@ -47,6 +52,7 @@ final class MobileModel {
4752 configFolder = folder
4853 configProblems = problems
4954 applySettings()
55 shareTemplates()
5056 watchConfig()
5157 }
5258
@@ -69,6 +75,7 @@ final class MobileModel {
6975 UserConfig.directoryOverride = configFolder
7076 configProblems = Self.readConfig(configFolder)
7177 applySettings()
78 shareTemplates()
7279 }
7380
7481 private func applySettings() {
@@ -84,6 +91,11 @@ final class MobileModel {
8491 settingsVersion += 1
8592 }
8693
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
8799 /// Edits to the folder's files, synced from the Mac, apply as they arrive.
88100 private func watchConfig() {
89101 presenter?.stop()
Sources/OrgstarMobile/OrgstarMobileApp.swift +25 −5
@@ -9,7 +9,7 @@ import UserNotifications
99@main
1010struct OrgstarMobileApp: App {
1111 @UIApplicationDelegateAdaptor private var delegate: AppDelegate
12 @State private var model = MobileModel()
12 @State private var model = MobileModel.shared
1313
1414 var body: some Scene {
1515 WindowGroup {
@@ -66,11 +66,21 @@ struct CaptureRequest: Identifiable {
6666 var properties: [String: String] = [:]
6767}
6868
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
6977struct RootView: View {
7078 let model: MobileModel
7179 let workspace: WorkspaceModel
7280 let agenda: AgendaModel
7381 @State private var capture: CaptureRequest?
82 /// Captures from the share extension after the one showing.
83 @State private var waiting: [CaptureRequest] = []
7484 @State private var tab = AppTab.agenda
7585 @State private var agendaPath = NavigationPath()
7686 @State private var hour = 0
@@ -143,10 +153,11 @@ struct RootView: View {
143153 // An open file takes in what changed on disk.
144154 if let path = workspace.changedFile?.path { session.fileChanged(path) }
145155 }
146 .sheet(item: $capture) { request in
156 .sheet(item: $capture, onDismiss: showWaiting) { request in
147157 CaptureScreen(workspace: workspace, session: session, request: request)
148158 }
149159 .task {
160 takeShared()
150161 // Development: `ORGSTAR_ROOT` adds a folder at launch, as on the Mac.
151162 if let root = ProcessInfo.processInfo.environment["ORGSTAR_ROOT"] { try? await workspace.addRoot(URL(fileURLWithPath: root)) }
152163 workspace.startWatching()
@@ -180,16 +191,25 @@ struct RootView: View {
180191 }
181192 // Files and settings synced while away; not every sync tool tells file presenters.
182193 guard phase == .active else { return }
194 takeShared()
183195 model.reloadConfig()
184196 Task { await workspace.refresh() }
185197 }
186198 .onOpenURL { url in
187 if case .capture(let template, let properties)? = OrgProtocol.parse(url.absoluteString) {
188 capture = CaptureRequest(template: template, properties: properties)
189 }
199 if let request = CaptureRequest(link: url.absoluteString) { capture = request }
190200 }
191201 }
192202
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
193213 private func clock(_ id: String) {
194214 switch id {
195215 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 {
6060 #expect(ours == result.text, "\(url)\nours: \(ours.debugDescription)\nemacs: \(result.text.debugDescription) \(result.error)")
6161 }
6262 }
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 }
6374}
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}"
99app=".build/ios-app/Orgstar.app"
1010products=".build/ios/Build/Products/Debug-iphonesimulator"
1111
12for scheme in OrgstarMobile OrgstarQuickLook; do
13 xcodebuild -scheme "$scheme" -destination 'generic/platform=iOS Simulator' -derivedDataPath .build/ios build -quiet
12# The entitlements (the app group the share extension leaves captures in) go where the
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"
1418done
1519
1620rm -rf "$app"
@@ -96,23 +100,35 @@ cat > "$app/Info.plist" <<PLIST
96100</plist>
97101PLIST
98102
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
103# App Intents metadata for Shortcuts, from the constant values the build extracted; building
104# a package doesn't run this step itself.
105toolchain="$(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain"
106objects=".build/ios/Build/Intermediates.noindex/Orgstar.build/Debug-iphonesimulator/OrgstarMobile-p.build/Objects-normal/arm64"
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
104120<?xml version="1.0" encoding="UTF-8"?>
105121<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
106122<plist version="1.0">
107123<dict>
108124 <key>CFBundleIdentifier</key>
109 <string>sh.krz.orgstar.quicklook</string>
125 <string>sh.krz.orgstar.$2</string>
110126 <key>CFBundleName</key>
111 <string>OrgstarQuickLook</string>
127 <string>$1</string>
112128 <key>CFBundleDisplayName</key>
113129 <string>Orgstar</string>
114130 <key>CFBundleExecutable</key>
115 <string>OrgstarQuickLook</string>
131 <string>$1</string>
116132 <key>CFBundlePackageType</key>
117133 <string>XPC!</string>
118134 <key>CFBundleShortVersionString</key>
@@ -126,21 +142,25 @@ cat > "$appex/Info.plist" <<PLIST
126142 <key>NSExtension</key>
127143 <dict>
128144 <key>NSExtensionPointIdentifier</key>
129 <string>com.apple.quicklook.preview</string>
145 <string>$3</string>
130146 <key>NSExtensionPrincipalClass</key>
131 <string>PreviewProvider</string>
147 <string>$4</string>
132148 <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>
149 <dict>$5</dict>
139150 </dict>
140151</dict>
141152</plist>
142153PLIST
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"
144163
145codesign --force --sign - "$app"
164codesign --force --sign - --entitlements "$entitlements" "$app"
165rm -rf "$work"
146166echo "$app"