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(
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"