iOS: share extension and Shortcuts action !120

merged merged by cmc on 2026-10-07 00:52 UTC · krz/orgstar:ios-share-shortcuts into main

15 files changed, +409 −43

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"