iOS: share extension and Shortcuts action !120
15 files changed, +409 −43
Layout: unified · split
Package.swift +4 −1
| @@ -20,7 +20,8 @@ let package = Package( | ||
| 20 | 20 | .executable(name: "Orgstar", targets: ["Orgstar"]), |
| 21 | 21 | .executable(name: "OrgstarMobile", targets: ["OrgstarMobile"]), |
| 22 | 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 | 26 | dependencies: [ |
| 26 | 27 | .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1"), |
| @@ -86,6 +87,8 @@ let package = Package( | ||
| 86 | 87 | // App extensions, bundled into Orgstar.app by scripts/build-app.sh. |
| 87 | 88 | .executableTarget(name: "OrgstarQuickLook", dependencies: ["OrgCore"], linkerSettings: extensionEntry), |
| 88 | 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 | 92 | .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]), |
| 90 | 93 | .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]), |
| 91 | 94 | .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]), |
README.md +4
| @@ -37,6 +37,10 @@ scripts/build-ios-app.sh # builds .build/ios-app/Orgstar.app | ||
| 37 | 37 | xcrun simctl install booted .build/ios-app/Orgstar.app |
| 38 | 38 | ``` |
| 39 | 39 | |
| 40 | On iOS, Orgstar in the share sheet takes a page or text with a template; the | |
| 41 | app opens its capture sheet with it when it's next active. Shortcuts has the | |
| 42 | same "Capture to Orgstar" action as on the Mac. | |
| 43 | ||
| 40 | 44 | Settings live in `~/.config/orgstar/config.toml` (Orgstar ▸ Edit Config File), |
| 41 | 45 | with keys in `keymap.toml` and capture templates in `capture.toml` beside it. |
| 42 | 46 | Settings ▸ General ▸ Import from Emacs reads an existing Emacs or Doom |
Sources/OrgApp/CaptureInbox.swift added +57
| @@ -0,0 +1,57 @@ | ||
| 1 | import 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. | |
| 6 | public 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 | |
| 151 | extension 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 | ||
| 151 | 174 | extension CaptureTemplate { |
| 152 | 175 | /// `id` and `clock` targets as a file and a heading. |
| 153 | 176 | @MainActor |
Sources/OrgCore/Links/OrgProtocol.swift +9
| @@ -49,6 +49,15 @@ public enum OrgProtocol { | ||
| 49 | 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 | 61 | /// `org-protocol-convert-query-to-plist` and `org-link-decode`: `+` is a space; a value |
| 53 | 62 | /// ends at its second `=`. |
| 54 | 63 | static func query(_ info: String) -> [(String, String?)] { |
Sources/Orgstar/CaptureIntent.swift +4 −16
| @@ -28,26 +28,14 @@ struct CaptureIntent: AppIntent { | ||
| 28 | 28 | // Launched for the intent, the window sets the models up shortly. |
| 29 | 29 | for _ in 0..<50 where Self.environment == nil { try? await Task.sleep(for: .milliseconds(100)) } |
| 30 | 30 | 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() | |
| 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 | 35 | switch placed { |
| 48 | 36 | case .failure(let error): |
| 49 | 37 | throw Failure(message: error.message) |
| 50 | case .success: | |
| 38 | case .success(let chosen): | |
| 51 | 39 | let message = "Captured to \(CaptureView.target(chosen))" |
| 52 | 40 | commands.show(message) |
| 53 | 41 | return .result(value: message, dialog: "\(message)") |
Sources/OrgstarMobile/CaptureIntent.swift added +48
| @@ -0,0 +1,48 @@ | ||
| 1 | #if os(iOS) | |
| 2 | import AppIntents | |
| 3 | import OrgApp | |
| 4 | import 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. | |
| 9 | struct 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 | ||
| 43 | struct 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 | 11 | @MainActor |
| 12 | 12 | @Observable |
| 13 | 13 | final class MobileModel { |
| 14 | /// One per process: Shortcuts runs the capture action without a window. | |
| 15 | static let shared = MobileModel() | |
| 16 | ||
| 14 | 17 | let workspace: WorkspaceModel? |
| 15 | 18 | let failure: String? |
| 16 | 19 | let session = DocumentSession() |
| @@ -18,6 +21,8 @@ final class MobileModel { | ||
| 18 | 21 | let scheduler = ReminderScheduler(center: SystemReminderCenter()) |
| 19 | 22 | let clock = ClockModel() |
| 20 | 23 | let spotlight = SpotlightIndexer() |
| 24 | /// Captures from the share extension; nil without the app group. | |
| 25 | let inbox = CaptureInbox() | |
| 21 | 26 | private(set) var configFolder: URL? |
| 22 | 27 | private(set) var configProblems: [String] = [] |
| 23 | 28 | /// An entry to show, from a tapped reminder. |
| @@ -28,7 +33,7 @@ final class MobileModel { | ||
| 28 | 33 | |
| 29 | 34 | private static let bookmarkKey = "configFolderBookmark" |
| 30 | 35 | |
| 31 | init() { | |
| 36 | private init() { | |
| 32 | 37 | // Development: `ORGSTAR_CONFIG_DIR` stands in for a chosen folder. |
| 33 | 38 | let folder = Self.resolveBookmark() ?? ProcessInfo.processInfo.environment["ORGSTAR_CONFIG_DIR"].map { URL(fileURLWithPath: $0) } |
| 34 | 39 | UserConfig.directoryOverride = folder |
| @@ -47,6 +52,7 @@ final class MobileModel { | ||
| 47 | 52 | configFolder = folder |
| 48 | 53 | configProblems = problems |
| 49 | 54 | applySettings() |
| 55 | shareTemplates() | |
| 50 | 56 | watchConfig() |
| 51 | 57 | } |
| 52 | 58 | |
| @@ -69,6 +75,7 @@ final class MobileModel { | ||
| 69 | 75 | UserConfig.directoryOverride = configFolder |
| 70 | 76 | configProblems = Self.readConfig(configFolder) |
| 71 | 77 | applySettings() |
| 78 | shareTemplates() | |
| 72 | 79 | } |
| 73 | 80 | |
| 74 | 81 | private func applySettings() { |
| @@ -84,6 +91,11 @@ final class MobileModel { | ||
| 84 | 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 | 99 | /// Edits to the folder's files, synced from the Mac, apply as they arrive. |
| 88 | 100 | private func watchConfig() { |
| 89 | 101 | presenter?.stop() |
Sources/OrgstarMobile/OrgstarMobileApp.swift +25 −5
| @@ -9,7 +9,7 @@ import UserNotifications | ||
| 9 | 9 | @main |
| 10 | 10 | struct OrgstarMobileApp: App { |
| 11 | 11 | @UIApplicationDelegateAdaptor private var delegate: AppDelegate |
| 12 | @State private var model = MobileModel() | |
| 12 | @State private var model = MobileModel.shared | |
| 13 | 13 | |
| 14 | 14 | var body: some Scene { |
| 15 | 15 | WindowGroup { |
| @@ -66,11 +66,21 @@ struct CaptureRequest: Identifiable { | ||
| 66 | 66 | var properties: [String: String] = [:] |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | extension 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 | ||
| 69 | 77 | struct RootView: View { |
| 70 | 78 | let model: MobileModel |
| 71 | 79 | let workspace: WorkspaceModel |
| 72 | 80 | let agenda: AgendaModel |
| 73 | 81 | @State private var capture: CaptureRequest? |
| 82 | /// Captures from the share extension after the one showing. | |
| 83 | @State private var waiting: [CaptureRequest] = [] | |
| 74 | 84 | @State private var tab = AppTab.agenda |
| 75 | 85 | @State private var agendaPath = NavigationPath() |
| 76 | 86 | @State private var hour = 0 |
| @@ -143,10 +153,11 @@ struct RootView: View { | ||
| 143 | 153 | // An open file takes in what changed on disk. |
| 144 | 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 | 157 | CaptureScreen(workspace: workspace, session: session, request: request) |
| 148 | 158 | } |
| 149 | 159 | .task { |
| 160 | takeShared() | |
| 150 | 161 | // Development: `ORGSTAR_ROOT` adds a folder at launch, as on the Mac. |
| 151 | 162 | if let root = ProcessInfo.processInfo.environment["ORGSTAR_ROOT"] { try? await workspace.addRoot(URL(fileURLWithPath: root)) } |
| 152 | 163 | workspace.startWatching() |
| @@ -180,16 +191,25 @@ struct RootView: View { | ||
| 180 | 191 | } |
| 181 | 192 | // Files and settings synced while away; not every sync tool tells file presenters. |
| 182 | 193 | guard phase == .active else { return } |
| 194 | takeShared() | |
| 183 | 195 | model.reloadConfig() |
| 184 | 196 | Task { await workspace.refresh() } |
| 185 | 197 | } |
| 186 | 198 | .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 } | |
| 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 | 213 | private func clock(_ id: String) { |
| 194 | 214 | switch id { |
| 195 | 215 | case "app.clock.out": message = model.clock.clockOut(session) |
Sources/OrgstarShare/ShareViewController.swift added +112
| @@ -0,0 +1,112 @@ | ||
| 1 | #if os(iOS) | |
| 2 | import OrgApp | |
| 3 | import OrgCore | |
| 4 | import SwiftUI | |
| 5 | import UIKit | |
| 6 | import 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) | |
| 11 | final 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. | |
| 33 | struct 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 | ||
| 59 | struct 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 @@ | ||
| 1 | import Foundation | |
| 2 | import OrgCore | |
| 3 | import OrgDocument | |
| 4 | import Testing | |
| 5 | @testable import OrgApp | |
| 6 | ||
| 7 | struct 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 | |
| 29 | struct 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 | 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}" | ||
| 9 | 9 | app=".build/ios-app/Orgstar.app" |
| 10 | 10 | products=".build/ios/Build/Products/Debug-iphonesimulator" |
| 11 | 11 | |
| 12 | for 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. | |
| 14 | entitlements=scripts/OrgstarMobile.entitlements | |
| 15 | for 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" | |
| 14 | 18 | done |
| 15 | 19 | |
| 16 | 20 | rm -rf "$app" |
| @@ -96,23 +100,35 @@ cat > "$app/Info.plist" <<PLIST | ||
| 96 | 100 | </plist> |
| 97 | 101 | PLIST |
| 98 | 102 | |
| 99 | # Quick Look previews of org files, in Files and wherever else Quick Look shows them. | |
| 100 | appex="$app/PlugIns/OrgstarQuickLook.appex" | |
| 101 | mkdir -p "$appex" | |
| 102 | cp "$products/OrgstarQuickLook" "$appex/OrgstarQuickLook" | |
| 103 | cat > "$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. | |
| 105 | toolchain="$(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain" | |
| 106 | objects=".build/ios/Build/Intermediates.noindex/Orgstar.build/Debug-iphonesimulator/OrgstarMobile-p.build/Objects-normal/arm64" | |
| 107 | work="$(mktemp -d)" | |
| 108 | ls "$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 | ||
| 115 | appex() { # 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 | 120 | <?xml version="1.0" encoding="UTF-8"?> |
| 105 | 121 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| 106 | 122 | <plist version="1.0"> |
| 107 | 123 | <dict> |
| 108 | 124 | <key>CFBundleIdentifier</key> |
| 109 | <string>sh.krz.orgstar.quicklook</string> | |
| 125 | <string>sh.krz.orgstar.$2</string> | |
| 110 | 126 | <key>CFBundleName</key> |
| 111 | <string>OrgstarQuickLook</string> | |
| 127 | <string>$1</string> | |
| 112 | 128 | <key>CFBundleDisplayName</key> |
| 113 | 129 | <string>Orgstar</string> |
| 114 | 130 | <key>CFBundleExecutable</key> |
| 115 | <string>OrgstarQuickLook</string> | |
| 131 | <string>$1</string> | |
| 116 | 132 | <key>CFBundlePackageType</key> |
| 117 | 133 | <string>XPC!</string> |
| 118 | 134 | <key>CFBundleShortVersionString</key> |
| @@ -126,21 +142,25 @@ cat > "$appex/Info.plist" <<PLIST | ||
| 126 | 142 | <key>NSExtension</key> |
| 127 | 143 | <dict> |
| 128 | 144 | <key>NSExtensionPointIdentifier</key> |
| 129 | <string>com.apple.quicklook.preview</string> | |
| 145 | <string>$3</string> | |
| 130 | 146 | <key>NSExtensionPrincipalClass</key> |
| 131 | <string>PreviewProvider</string> | |
| 147 | <string>$4</string> | |
| 132 | 148 | <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> | |
| 139 | 150 | </dict> |
| 140 | 151 | </dict> |
| 141 | 152 | </plist> |
| 142 | 153 | PLIST |
| 143 | codesign --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. | |
| 157 | appex 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. | |
| 160 | appex 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 | |
| 145 | codesign --force --sign - "$app" | |
| 164 | codesign --force --sign - --entitlements "$entitlements" "$app" | |
| 165 | rm -rf "$work" | |
| 146 | 166 | echo "$app" |