Eight unpushed commits implement the full share-extension spec end-to-end — a new PrismShareExtension target plus the receive-side wiring in the main app. The latest commit applies fixes from this review.
PrismShareExtension target (iOS + macOS) plus the main-app receive side. URLs travel the existing prism://open?url= route; files travel through group.me.nore.ig.prism/shared-content/{uuid}.json and arrive via the new prism://open-shared?id= route.DocumentSource.shared, SharedContentPayload roundtrip, SharedContentManager load/delete/cleanup, the new DocumentSession initializer, and DocumentFlowCoordinator URL routing.group.me.nore.ig.prism identifier; Decisions 15–17 added in this review cover the test seam, the wire-format pin, and the defensive notes fallback.me.nore.ig.prism.PrismShareExtension to be registered with the developer account before xcodebuild can sign it — open the target in Xcode once.Ready to push
All five findings raised by the parallel review agents are fixed in commit 16b2ef8. make build-ios, xcodebuild build for macOS (with signing skipped), and make lint are all clean. Three follow-ups (extension-target unit tests, a firstQueryValue URL-parsing helper, and a documentation pass to rewrite the spec's capital-P App Group references) are filed as worth-doing-later rather than blocking the merge.
3cd149b T-431: Scaffold PrismShareExtension target and App Group a499ffc T-431: Share Extension data models (Phase 1) 735925d [doc]: Mark Share Extension spec as In Progress in OVERVIEW 4948452 T-431: SharedContentManager + decoder factory (Phase 2) 6f5f1f3 T-431: Wire prism://open-shared into the main app (Phase 3) db3b710 T-431: PrismShareExtension implementation (Phase 4) b07571e T-431: Build verification + version sync (Phase 5) 16b2ef8 T-431: Apply pre-push review fixes Prism can now appear in the iOS, iPadOS, and macOS share sheet. Tap Share in Safari on a Markdown URL, or share a .md file from Files or Mail, and Prism shows up as a destination. Pick it; Prism opens with the shared content already loaded.
There are two cases under the hood. Shared URLs — like a link from Safari — get re-routed through Prism's existing remote-URL handler; the extension never touches the bytes. Shared files are trickier: the file's content can't cross the sandbox boundary directly, so the extension writes the content plus its filename and a timestamp into a small JSON file in a shared folder both apps can see, then wakes the main app with a URL like prism://open-shared?id=…. The main app reads the JSON back, displays the document, and deletes the file.
Before this, the only way to view a Markdown file from another app was: leave the other app, open Prism, paste the URL or pick the file. Now: select → Share → Prism. Same for files attached to emails.
prism://open-shared?id=… is the 'phone number' the system uses to wake up the main app. The extension dials it; the main app picks up.Two halves communicate through two channels:
group.me.nore.ig.prism/shared-content/ — single-file JSON payload ({uuid}.json) per share. Decision 12 supersedes the earlier content + sidecar design so the handoff is genuinely atomic.prism://open?url=… for URLs (reuses the existing handler) and prism://open-shared?id={uuid} for files.ShareViewController is platform-branched (UIViewController on iOS, NSViewController on macOS) but trivial: it hosts a SwiftUI view via the corresponding hosting controller.ShareExtensionView is minimal — a ProgressView while processing, a warning card with a Done button on error.ShareExtensionViewModel drives the flow: validate the single-item share, extract via ContentExtractor, route to openMainAppForURL or writeAndOpen, dismiss.ContentExtractor probes attachments in strict type-priority order: net.daringfireball.markdown → public.file-url (only .md / .markdown extensions) → public.url (only http / https). The order is load-bearing — public.file-url conforms to public.url, so checking URL first would misclassify shared .md files.SharedContentWriter atomically writes the payload as {uuid}.json to the App Group container.DocumentSource.shared(filename:) joins the existing .file, .clipboard, .bundled, .url cases. supportsNotes == false, imageBaseURL == nil (Req 4.5), no recent-files entry, no state persistence.DocumentSession.init(sharedContent:filename:) mirrors the remote-URL initializer.DocumentFlowCoordinator.handleOpenURL gains a prism://open-shared branch. UUID(uuidString:) is the only path-traversal guard before any filesystem touch.SharedContentManager reads + deletes payloads and sweeps stale ones older than 24 h. nonisolated static API. A containerURLOverride test seam is #if DEBUG-gated.prismApp dispatches cleanupStaleFiles() on a Task.detached 2 s after the WindowGroup body appears, so any pending onOpenURL fires first.TRUEPREDICATE — Prism appears in the share sheet for all file types, runtime rejects non-Markdown. Cost: noise; benefit: no risk of silent fallback to TRUEPREDICATE on parse failure.group.me.nore.ig.prism — Xcode's auto-sync registered the lowercase form; renaming the portal record was disproportionate to alignment with the spec's capital-P text. Decision 14 documents.The extension and the main app are separate sandboxes. They share three channels: the App Group container (mounted at containerURL(forSecurityApplicationGroupIdentifier:) in both processes), URL schemes (one process asks the system to launch another), and the pasteboard (leaky, size-limited). The design uses #1 and #2 together — the URL scheme is the wake-up signal, the App Group is the data channel. URL-scheme alone tops out around 2 KB before various OS components silently truncate.
Custom URL schemes are unsigned — any other app on the device can invoke prism://open-shared?id=…. A design with ?file={filename} would let a malicious caller exfiltrate via ?file=../../somewhere/important.txt. The defence sits at the URL parser:
let idString = components.queryItems?.first(where: { $0.name == "id" })?.value,
let id = UUID(uuidString: idString)UUID(uuidString:) accepts only the canonical hex+dashes form. Nothing else passes. The downstream fileURL(for:) builds "\(id.uuidString).json" — pure hex + dashes — so there is no string-based filename sanitisation anywhere in the stack because there is no untrusted filename in the stack. Decision 8 documents the approach.
The project sets SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, so unannotated types and functions are MainActor-isolated. The cleanup sweep started life MainActor-isolated — its .task ran on the main thread, and contentsOfDirectory + per-file resourceValues + removeItem would have blocked it.
After the pre-push review fix: SharedContentManager's public API and private helpers are all nonisolated static, the constants are nonisolated static let, and SharedContentPayload is nonisolated struct … : Sendable so its synthesised Decodable conformance is reachable from off-main contexts. prismApp.swift dispatches the cleanup body onto Task.detached(priority: .background). The cleanup runs at most once per MainContentView lifetime — which is once per window on macOS — but subsequent cleanups are cheap because the first sweep emptied anything older than 24 h.
The payload struct is duplicated in the two targets. Without explicit pinning, a Swift/Foundation update that changed JSONEncoder.dateEncodingStrategy's default would silently break the handoff. The makeEncoder() / makeDecoder() factories on SharedContentPayload make the contract explicit, ISO-8601 so payloads are human-readable when debugging, and used by tests, the writer, and the reader equally. Decision 16 records the rationale.
.md files do not resolve (Req 3.9, Decision 11). The image siblings never crossed the sandbox; imageBaseURL == nil for .shared. There's no NSItemProvider mechanism for sibling-asset enumeration. Users see the existing image-error placeholder.public.plain-text makes Prism appear in the share sheet for every text-sharing interaction system-wide. Cost outweighed value; clipboard paste remains the alternative.NSItemProvider.loadItem(forTypeIdentifier:) sometimes returns a String rather than a URL. ContentExtractor.extractURL handles both.cleanupStaleFiles() is the hard guard. The 2-second .task delay is the cheap suspenders — it lets onOpenURL fire first for any pending open-shared URL..task re-fires per window. Acceptable because the first cleanup empties the stale set; subsequent runs are O(few files).prism/ViewModels/DocumentFlowCoordinator.swift
Why it matters. Custom URL schemes are unsigned — any app on the device can invoke prism://open-shared?id=…. The UUID parse at handleOpenURL is the only line of defence between an attacker and the App Group container.
What to look at. DocumentFlowCoordinator.swift:117-128
PrismShareExtension/SharedContentWriter.swift
Why it matters. Earlier design used content + metadata sidecar files; if the extension crashed between writes, an orphaned content file would persist without metadata. Single-file payload eliminates that partial-write window entirely.
What to look at. SharedContentWriter.swift:33-50
prism/Models/SharedContentPayload.swift
Why it matters. The payload struct is duplicated across two targets. Without an explicit format pin, a Swift/Foundation default-strategy change would silently break decoding in one target.
What to look at. SharedContentPayload.swift:35-47
prism/prismApp.swift
Why it matters. cleanupStaleFiles walks the App Group directory and removes stale entries — pure filesystem I/O that would block the main thread if dispatched naively, and on macOS the .task is re-issued per window.
What to look at. prismApp.swift:379-395
prism/Services/SharedContentManager.swift
Why it matters. containerURLOverride is a mutable static needed only by tests. Without #if DEBUG it would ship in release binaries as a #nonisolated(unsafe)# global — a real, if narrow, attack surface for any code that could write to it.
What to look at. SharedContentManager.swift:42-52, :127-133
PrismShareExtension/ContentExtractor.swift
Why it matters. public.file-url conforms to public.url in Apple's UTType hierarchy. Probing URL before file-url misclassifies shared .md files as URLs and immediately rejects them as 'Unsupported URL scheme' (file://).
What to look at. ContentExtractor.swift:43-62
Custom URL schemes can be invoked by any app. Embedding raw filenames as the lookup key is a path-traversal vector. UUIDs are constrained to hex + dashes by UUID(uuidString:), so the downstream filesystem operations are safe by construction. Original filenames live in payload metadata, not in the URL.
Two-file handoff has a partial-write window if the extension crashes between writes. Single {uuid}.json with content and metadata together collapses the handoff to one atomic write. JSON encoding overhead is negligible for the 10 MB cap.
NSExtensionActivationRule predicate strings silently fall back to TRUEPREDICATE on parse failure — meaning Prism would activate for every share. Dictionary rules (NSExtensionActivationSupportsWebURLWithMaxCount + NSExtensionActivationSupportsFileWithMaxCount) are validated at install time. UTType-level filtering happens at runtime in ContentExtractor.
Xcode's automatic developer-portal sync registered the lowercase form; the spec text used capital-P by analogy with the iCloud container. App Group identifiers are case-sensitive; the implementation must match exactly what was registered. The spec text is documentation drift, recorded explicitly so future readers know which form is authoritative.
Tests need to redirect filesystem operations away from the real App Group. nonisolated(unsafe) static var gated by #if DEBUG matches the existing setRemoteCoordinatorForTests pattern, ships zero footprint in release builds, and avoids the overhead of converting the static-enum API into an injected protocol.
The payload struct is duplicated across two targets. Pinning the date strategy explicitly to ISO-8601 — and reusing the factory pair everywhere — defends against an Apple-side default change and keeps payloads human-readable for debugging.
DocumentSource.supportsNotes returns false for .shared, so callers should skip notes. But resolveNoteContext is reachable from defensive paths and must be total. Routing through resolve(forClipboardSession:) keeps any accidental writes session-scoped (and therefore self-cleaning), avoiding a crash path while documenting the contract.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | DocumentFlowCoordinator.swift | openSharedDocument used an ad-hoc inline 'alert.openShared.notFound' string for the missing-payload error; the SharedContentError enum and its three localised keys (error.sharedContent.*) were dead code in the main target. | Wired SharedContentError.contentNotFound.errorDescription into the loadError path so the keys are referenced from a single source. |
| major | SharedContentManager.swift | containerURLOverride was a nonisolated(unsafe) static var on a production type, shipping in release builds. | Both the variable and the override branch in sharedContainerURL() are now #if DEBUG-gated, matching the setRemoteCoordinatorForTests pattern. |
| major | prismApp.swift / SharedContentManager.swift | cleanupStaleFiles() ran on MainActor via .task and was re-issued per MainContentView lifetime (per-window on macOS). Filesystem walk blocked the main thread. | Marked the SharedContentManager static API nonisolated; prismApp dispatches cleanup onto Task.detached(priority: .background). Also made SharedContentPayload nonisolated + Sendable so the Decodable conformance reaches off-main contexts. |
| minor | specs/share-extension/decision_log.md | Three implementation choices not yet recorded as decisions: the DEBUG-only test seam, the ISO-8601 factory pair on SharedContentPayload, and the NotesManager.resolveNoteContext defensive fallback for .shared. | Added Decisions 15, 16, 17. |
| minor | CLAUDE.md | Top-level project notes did not mention the new PrismShareExtension target or its App Group. | Added a one-line entry under the spec listing pointing at share-extension/ with the lowercase identifier called out. |
| minor | DocumentFlowCoordinator.swift | Three URL-routing branches in handleOpenURL repeat the URLComponents(url:) + queryItems.first(where:) pattern. | Pre-existing pattern; with only two callers in the queryItems form, inlining is more readable than a helper. Worth a firstQueryValue(in:name:) extraction if a third URL scheme branch lands. |
| minor | ImagePathResolver.swift | Dead 'case .clipboard, .shared:' branches inside switches whose early-returns already cover both. LinkPathResolver has explanatory comments; ImagePathResolver does not. | Cosmetic inconsistency; deferred. |
| minor | ContentExtractor / SharedContentWriter / ShareExtensionViewModel | No unit tests for the extension target's runtime behaviour. design.md notes extractor tests 'can be tested in main target with mock providers' but no task was created. | Acknowledged as a follow-up — recommend filing a task to add NSItemProvider-mock tests for ContentExtractor and a test for SharedContentWriter's atomic-write path. |
Click to expand.
diff --git a/PrismShareExtension/ContentExtractor.swift b/PrismShareExtension/ContentExtractor.swift
new file mode 100644
index 0000000..80eabf0
--- /dev/null
+++ b/PrismShareExtension/ContentExtractor.swift
@@ -0,0 +1,162 @@
+//
+// ContentExtractor.swift
+// PrismShareExtension
+//
+// Extracts a markdown file or http(s) URL from the share sheet's first
+// attachment, in type-priority order. URLs short-circuit straight to the
+// main app via the existing `prism://open?url=` scheme; markdown files
+// travel through the App Group container (T-431, Reqs 2.x/3.x/6.x/8.x).
+//
+
+import Foundation
+import UniformTypeIdentifiers
+
+/// Result of running the type-priority extraction against an
+/// `NSExtensionItem`. The view model decides whether to dispatch to
+/// `openMainAppForURL` (URL case) or `writeAndOpen` (markdownFile case).
+enum ExtractedContent {
+ case url(URL)
+ case markdownFile(content: String, filename: String)
+ case unsupported
+ case error(String)
+}
+
+/// Pulls a single supported attachment out of an `NSExtensionItem`.
+///
+/// **Priority order (Req 6.1, Decision 13):** markdown UTType → file URL →
+/// web URL. This sequence is load-bearing: `public.file-url` conforms to
+/// `public.url` in Apple's UTType hierarchy, so checking `public.url` first
+/// would misclassify shared `.md` files as URLs and immediately reject them
+/// as "Unsupported URL scheme" (file://).
+enum ContentExtractor {
+ /// 10 MB cap matches the main app's `MarkdownDocument` size limit.
+ static let maxFileSize = 10_485_760
+
+ /// Markdown UTType identifier (declared by the Files app and Mail).
+ static let markdownUTType = "net.daringfireball.markdown"
+
+ /// Extracts a supported attachment from `item`. Returns `.unsupported`
+ /// for items we can't handle (image/binary/etc) and `.error(message)`
+ /// for cases where the attachment was claimed but turned out to be
+ /// invalid (oversized, not UTF-8, file-read failure).
+ static func extract(from item: NSExtensionItem) async -> ExtractedContent {
+ guard let attachments = item.attachments, let provider = attachments.first else {
+ return .unsupported
+ }
+
+ if provider.hasItemConformingToTypeIdentifier(markdownUTType) {
+ return await extractMarkdownFile(from: provider)
+ }
+
+ if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
+ return await extractFileURL(from: provider)
+ }
+
+ if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
+ return await extractURL(from: provider)
+ }
+
+ return .unsupported
+ }
+
+ // MARK: - Type-specific extractors
+
+ /// Extracts an http(s) URL, rejecting any other scheme (Req 2.6).
+ /// Also accepts a string representation in case the sharing app
+ /// delivered the URL as plain text (Req 2.5).
+ private static func extractURL(from provider: NSItemProvider) async -> ExtractedContent {
+ do {
+ let item = try await provider.loadItem(forTypeIdentifier: UTType.url.identifier)
+
+ let url: URL?
+ if let directURL = item as? URL {
+ url = directURL
+ } else if let urlString = item as? String, let parsed = URL(string: urlString) {
+ url = parsed
+ } else {
+ url = nil
+ }
+
+ guard let url else { return .unsupported }
+
+ guard let scheme = url.scheme?.lowercased(),
+ scheme == "http" || scheme == "https" else {
+ return .error(String(localized: "shareExt.error.unsupportedURLScheme",
+ defaultValue: "Unsupported URL scheme"))
+ }
+ return .url(url)
+ } catch {
+ return .error(String(localized: "shareExt.error.couldNotReadURL",
+ defaultValue: "Could not read shared URL"))
+ }
+ }
+
+ /// Extracts a markdown file delivered as `net.daringfireball.markdown`.
+ /// The item may arrive as a `URL` (on-disk file) or as `Data` (in-memory
+ /// payload). Validates the 10 MB cap (Req 8.4) and UTF-8 encoding
+ /// (Req 8.5) before returning success.
+ private static func extractMarkdownFile(from provider: NSItemProvider) async -> ExtractedContent {
+ do {
+ let item = try await provider.loadItem(forTypeIdentifier: markdownUTType)
+
+ if let url = item as? URL {
+ return loadMarkdownFromURL(url)
+ }
+ if let data = item as? Data {
+ return validateMarkdownPayload(data, filename: "Shared.md")
+ }
+ return .unsupported
+ } catch {
+ return .error(String(localized: "shareExt.error.couldNotReadFile",
+ defaultValue: "Could not read shared file"))
+ }
+ }
+
+ /// Extracts a generic file URL and accepts it as markdown only if the
+ /// extension is `.md` or `.markdown` (Req 6.3). Files with any other
+ /// extension are reported as `.unsupported`.
+ private static func extractFileURL(from provider: NSItemProvider) async -> ExtractedContent {
+ do {
+ let item = try await provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier)
+ guard let url = item as? URL else { return .unsupported }
+
+ let ext = url.pathExtension.lowercased()
+ guard ext == "md" || ext == "markdown" else {
+ return .unsupported
+ }
+
+ return loadMarkdownFromURL(url)
+ } catch {
+ return .error(String(localized: "shareExt.error.couldNotReadFile",
+ defaultValue: "Could not read shared file"))
+ }
+ }
+
+ // MARK: - Validation
+
+ /// Reads `url` from disk and runs the size + UTF-8 checks. Errors short
+ /// out to a localised message rather than throwing.
+ private static func loadMarkdownFromURL(_ url: URL) -> ExtractedContent {
+ let data: Data
+ do {
+ data = try Data(contentsOf: url)
+ } catch {
+ return .error(String(localized: "shareExt.error.couldNotReadFile",
+ defaultValue: "Could not read shared file"))
+ }
+ return validateMarkdownPayload(data, filename: url.lastPathComponent)
+ }
+
+ /// Enforces the 10 MB cap (Req 8.4) and UTF-8 encoding (Req 8.5).
+ private static func validateMarkdownPayload(_ data: Data, filename: String) -> ExtractedContent {
+ guard data.count <= maxFileSize else {
+ return .error(String(localized: "shareExt.error.fileTooLarge",
+ defaultValue: "File is too large (10MB limit)"))
+ }
+ guard let content = String(data: data, encoding: .utf8) else {
+ return .error(String(localized: "shareExt.error.notValidText",
+ defaultValue: "File is not a valid text document"))
+ }
+ return .markdownFile(content: content, filename: filename)
+ }
+}
diff --git a/PrismShareExtension/ShareExtensionViewModel.swift b/PrismShareExtension/ShareExtensionViewModel.swift
new file mode 100644
index 0000000..5504c46
--- /dev/null
+++ b/PrismShareExtension/ShareExtensionViewModel.swift
@@ -0,0 +1,178 @@
+//
+// ShareExtensionViewModel.swift
+// PrismShareExtension
+//
+// Drives the minimal extension UI: validate the share item, extract its
+// content via `ContentExtractor`, write it to the App Group container (for
+// files) or build a `prism://open?url=` URL (for URLs), open the main app,
+// and dismiss. Surfaces transient errors to `ShareExtensionView` (T-431).
+//
+
+import Foundation
+#if os(iOS)
+import UIKit
+#elseif os(macOS)
+import AppKit
+#endif
+
+@Observable
+@MainActor
+final class ShareExtensionViewModel {
+ enum State: Equatable {
+ case processing
+ case error(String)
+ }
+
+ private(set) var state: State = .processing
+
+ private weak var extensionContext: NSExtensionContext?
+
+ /// Token for the active auto-dismiss timer so a second error replaces
+ /// the timer instead of stacking.
+ private var dismissTask: Task<Void, Never>?
+
+ init(extensionContext: NSExtensionContext?) {
+ self.extensionContext = extensionContext
+ }
+
+ /// Entry point invoked from `ShareExtensionView.task`. Walks the share
+ /// item end-to-end: validate, extract, route, dismiss.
+ func processSharedContent() async {
+ guard let extensionContext else {
+ showError(String(localized: "shareExt.error.noContent",
+ defaultValue: "No content to share"))
+ return
+ }
+
+ // Multi-item shares are out of scope (Req 8.7).
+ if extensionContext.inputItems.count > 1 {
+ showError(String(localized: "shareExt.error.multipleItems",
+ defaultValue: "Please share one item at a time"))
+ return
+ }
+
+ guard let item = extensionContext.inputItems.first as? NSExtensionItem else {
+ showError(String(localized: "shareExt.error.noContent",
+ defaultValue: "No content to share"))
+ return
+ }
+
+ let result = await ContentExtractor.extract(from: item)
+ switch result {
+ case .url(let url):
+ openMainAppForURL(url)
+ case .markdownFile(let content, let filename):
+ writeAndOpen(content: content, filename: filename)
+ case .unsupported:
+ showError(String(localized: "shareExt.error.unsupported",
+ defaultValue: "Unsupported content type"))
+ case .error(let message):
+ showError(message)
+ }
+ }
+
+ /// Dismisses the extension. Called by the "Done" button and by the
+ /// auto-dismiss timer.
+ func dismiss() {
+ dismissTask?.cancel()
+ dismissTask = nil
+ extensionContext?.cancelRequest(withError: NSError(
+ domain: "me.nore.ig.prism.PrismShareExtension",
+ code: 1
+ ))
+ }
+
+ // MARK: - Routing
+
+ private func openMainAppForURL(_ url: URL) {
+ var components = URLComponents()
+ components.scheme = "prism"
+ components.host = "open"
+ components.queryItems = [URLQueryItem(name: "url", value: url.absoluteString)]
+
+ guard let prismURL = components.url else {
+ showError(String(localized: "shareExt.error.couldNotOpen",
+ defaultValue: "Could not open Prism"))
+ return
+ }
+ openMainApp(with: prismURL)
+ }
+
+ private func writeAndOpen(content: String, filename: String) {
+ let id: UUID
+ do {
+ id = try SharedContentWriter.write(content: content, filename: filename)
+ } catch {
+ showError(String(localized: "shareExt.error.couldNotSave",
+ defaultValue: "Could not save shared content"))
+ return
+ }
+
+ var components = URLComponents()
+ components.scheme = "prism"
+ components.host = "open-shared"
+ components.queryItems = [URLQueryItem(name: "id", value: id.uuidString)]
+
+ guard let prismURL = components.url else {
+ showError(String(localized: "shareExt.error.couldNotOpen",
+ defaultValue: "Could not open Prism"))
+ return
+ }
+ openMainApp(with: prismURL)
+ }
+
+ /// Uses `NSExtensionContext.open(_:completionHandler:)` on both
+ /// platforms (Req 10.1). Older Apple guidance suggested
+ /// `UIApplication.shared.open()` was needed on iOS, but it's
+ /// unavailable in extensions — `extensionContext.open(_:)` is the
+ /// supported call.
+ private func openMainApp(with prismURL: URL) {
+ guard let extensionContext else {
+ showError(String(localized: "shareExt.error.couldNotOpen",
+ defaultValue: "Could not open Prism"))
+ return
+ }
+ extensionContext.open(prismURL) { [weak self] success in
+ // The completion handler delivers on an arbitrary thread —
+ // hop back to the main actor for state mutation and dismiss.
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ if success {
+ self.extensionContext?.completeRequest(returningItems: nil)
+ } else {
+ self.showError(String(localized: "shareExt.error.couldNotOpen",
+ defaultValue: "Could not open Prism"))
+ }
+ }
+ }
+ }
+
+ // MARK: - Errors
+
+ /// Surfaces `message`, schedules a 3-second auto-dismiss for non
+ /// VoiceOver users, and lets VoiceOver users dismiss manually via the
+ /// "Done" button (Reqs 7.4–7.5, 8.8).
+ private func showError(_ message: String) {
+ state = .error(message)
+ dismissTask?.cancel()
+ dismissTask = nil
+
+ guard !Self.isVoiceOverRunning else { return }
+
+ dismissTask = Task { [weak self] in
+ try? await Task.sleep(for: .seconds(3))
+ guard let self, !Task.isCancelled else { return }
+ self.dismiss()
+ }
+ }
+
+ private static var isVoiceOverRunning: Bool {
+ #if os(iOS)
+ return UIAccessibility.isVoiceOverRunning
+ #elseif os(macOS)
+ return NSWorkspace.shared.isVoiceOverEnabled
+ #else
+ return false
+ #endif
+ }
+}
diff --git a/PrismShareExtension/ShareExtensionView.swift b/PrismShareExtension/ShareExtensionView.swift
new file mode 100644
index 0000000..0d87ffb
--- /dev/null
+++ b/PrismShareExtension/ShareExtensionView.swift
@@ -0,0 +1,45 @@
+//
+// ShareExtensionView.swift
+// PrismShareExtension
+//
+// Minimal SwiftUI surface: a loading indicator while the extension is
+// shuttling content to Prism, or a brief error card with a "Done" button
+// (T-431, Reqs 7.1–7.7).
+//
+
+import SwiftUI
+
+struct ShareExtensionView: View {
+ @Bindable var viewModel: ShareExtensionViewModel
+
+ var body: some View {
+ Group {
+ switch viewModel.state {
+ case .processing:
+ ProgressView {
+ Text("shareExt.opening", comment: "Status text while the share extension is handing off to the main app")
+ }
+ case .error(let message):
+ VStack(spacing: 12) {
+ Image(systemName: "exclamationmark.triangle")
+ .font(.title2)
+ .foregroundStyle(.secondary)
+ Text(message)
+ .font(.callout)
+ .multilineTextAlignment(.center)
+ Button {
+ viewModel.dismiss()
+ } label: {
+ Text("shareExt.done", comment: "Button to dismiss the share-extension error sheet")
+ }
+ .buttonStyle(.bordered)
+ }
+ .accessibilityElement(children: .combine)
+ }
+ }
+ .padding()
+ .task {
+ await viewModel.processSharedContent()
+ }
+ }
+}
diff --git a/PrismShareExtension/ShareViewController.swift b/PrismShareExtension/ShareViewController.swift
new file mode 100644
index 0000000..057c4ee
--- /dev/null
+++ b/PrismShareExtension/ShareViewController.swift
@@ -0,0 +1,51 @@
+//
+// ShareViewController.swift
+// PrismShareExtension
+//
+// Extension principal class. Replaces the Xcode-generated
+// SLComposeServiceViewController scaffold with a thin UIViewController
+// (iOS) / NSViewController (macOS) that hosts `ShareExtensionView` via
+// `UIHostingController` / `NSHostingController` (T-431, Req 1.7).
+//
+
+import SwiftUI
+
+#if os(iOS)
+import UIKit
+
+final class ShareViewController: UIViewController {
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ let viewModel = ShareExtensionViewModel(extensionContext: extensionContext)
+ let hosting = UIHostingController(rootView: ShareExtensionView(viewModel: viewModel))
+
+ addChild(hosting)
+ hosting.view.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(hosting.view)
+ NSLayoutConstraint.activate([
+ hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ hosting.view.topAnchor.constraint(equalTo: view.topAnchor),
+ hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
+ ])
+ hosting.didMove(toParent: self)
+ }
+}
+#elseif os(macOS)
+import AppKit
+
+final class ShareViewController: NSViewController {
+ override func loadView() {
+ let viewModel = ShareExtensionViewModel(extensionContext: extensionContext)
+ let hosting = NSHostingController(rootView: ShareExtensionView(viewModel: viewModel))
+
+ // Give the macOS share window a reasonable starting size; SwiftUI
+ // will compress/expand around the loading indicator or error card.
+ hosting.view.frame = NSRect(x: 0, y: 0, width: 360, height: 160)
+
+ addChild(hosting)
+ view = hosting.view
+ }
+}
+#endif
diff --git a/PrismShareExtension/SharedContentWriter.swift b/PrismShareExtension/SharedContentWriter.swift
new file mode 100644
index 0000000..a5e64c3
--- /dev/null
+++ b/PrismShareExtension/SharedContentWriter.swift
@@ -0,0 +1,67 @@
+//
+// SharedContentWriter.swift
+// PrismShareExtension
+//
+// Atomically writes the SharedContentPayload to the App Group container so
+// the main app's SharedContentManager can pick it up via the
+// `prism://open-shared?id={uuid}` URL (T-431, Reqs 3.3–3.5, 5.2–5.4, 5.7).
+//
+
+import Foundation
+
+/// Writes a `SharedContentPayload` to the App Group container as a single
+/// atomic JSON file. The returned UUID is what the extension passes back to
+/// the main app via the URL scheme.
+///
+/// Single-file payload (Decision 12) eliminates partial-write risk: either
+/// the JSON exists with both content and metadata, or it doesn't.
+enum SharedContentWriter {
+ /// Matches `SharedContentManager.appGroupID` in the main app — both must
+ /// agree exactly. See spec Decision 14 for why this is lowercase.
+ static let appGroupID = "group.me.nore.ig.prism"
+
+ /// Matches `SharedContentManager.subdirectory`.
+ static let subdirectory = "shared-content"
+
+ /// Writes the content + metadata as `{uuid}.json` and returns the UUID.
+ ///
+ /// Throws `SharedContentError.appGroupUnavailable` if the App Group
+ /// container can't be resolved (missing entitlement / unprovisioned),
+ /// or `.writeFailure` if encoding or the atomic write fails.
+ static func write(content: String, filename: String) throws -> UUID {
+ let id = UUID()
+ let containerURL = try sharedContainerURL()
+ let fileURL = containerURL.appendingPathComponent("\(id.uuidString).json")
+
+ let payload = SharedContentPayload(
+ originalFilename: filename,
+ createdAt: Date(),
+ content: content
+ )
+
+ do {
+ let data = try SharedContentPayload.makeEncoder().encode(payload)
+ try data.write(to: fileURL, options: .atomic)
+ } catch {
+ throw SharedContentError.writeFailure
+ }
+
+ return id
+ }
+
+ private static func sharedContainerURL() throws -> URL {
+ guard let containerURL = FileManager.default.containerURL(
+ forSecurityApplicationGroupIdentifier: appGroupID
+ ) else {
+ throw SharedContentError.appGroupUnavailable
+ }
+
+ let directoryURL = containerURL.appendingPathComponent(subdirectory)
+ // `withIntermediateDirectories: true` makes this a no-op when the
+ // directory already exists, which it usually does after the first
+ // share. Failure to create is rare — fall through and let the
+ // subsequent write fail with the underlying error.
+ try? FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
+ return directoryURL
+ }
+}
diff --git a/PrismShareExtension/SharedContentPayload.swift b/PrismShareExtension/SharedContentPayload.swift
new file mode 100644
index 0000000..274bf9d
--- /dev/null
+++ b/PrismShareExtension/SharedContentPayload.swift
@@ -0,0 +1,30 @@
+//
+// SharedContentPayload.swift
+// PrismShareExtension
+//
+// Duplicate of `prism/Models/SharedContentPayload.swift`. Both copies must
+// stay byte-compatible: the field names form the on-wire JSON contract.
+// See Decision 12 — keeping the struct in both targets avoids introducing
+// a Swift Package for a single Codable. The wire format is pinned by the
+// `makeEncoder()` / `makeDecoder()` factories.
+//
+
+import Foundation
+
+nonisolated struct SharedContentPayload: Codable, Equatable, Sendable {
+ let originalFilename: String
+ let createdAt: Date
+ let content: String
+
+ nonisolated static func makeEncoder() -> JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ return encoder
+ }
+
+ nonisolated static func makeDecoder() -> JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return decoder
+ }
+}
diff --git a/PrismShareExtension/SharedContentError.swift b/PrismShareExtension/SharedContentError.swift
new file mode 100644
index 0000000..c86f29a
--- /dev/null
+++ b/PrismShareExtension/SharedContentError.swift
@@ -0,0 +1,30 @@
+//
+// SharedContentError.swift
+// PrismShareExtension
+//
+// Duplicate of `prism/Models/SharedContentError.swift` for the extension
+// target. See Decision 12 — duplication is intentional; both targets must
+// agree on the case names so callers can react identically.
+//
+
+import Foundation
+
+enum SharedContentError: Error, LocalizedError, Equatable {
+ case appGroupUnavailable
+ case writeFailure
+ case contentNotFound
+
+ var errorDescription: String? {
+ switch self {
+ case .appGroupUnavailable:
+ return String(localized: "error.sharedContent.appGroupUnavailable",
+ defaultValue: "Could not access shared storage")
+ case .writeFailure:
+ return String(localized: "error.sharedContent.writeFailure",
+ defaultValue: "Could not save shared content")
+ case .contentNotFound:
+ return String(localized: "error.sharedContent.contentNotFound",
+ defaultValue: "Shared content not found")
+ }
+ }
+}
diff --git a/PrismShareExtension/Info.plist b/PrismShareExtension/Info.plist
new file mode 100644
index 0000000..6c0c36b
--- /dev/null
+++ b/PrismShareExtension/Info.plist
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>NSExtension</key>
+ <dict>
+ <key>NSExtensionAttributes</key>
+ <dict>
+ <key>NSExtensionActivationRule</key>
+ <dict>
+ <key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
+ <integer>1</integer>
+ <key>NSExtensionActivationSupportsFileWithMaxCount</key>
+ <integer>1</integer>
+ </dict>
+ </dict>
+ <key>NSExtensionPointIdentifier</key>
+ <string>com.apple.share-services</string>
+ <key>NSExtensionPrincipalClass</key>
+ <string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
+ </dict>
+</dict>
+</plist>
diff --git a/PrismShareExtension/PrismShareExtension.entitlements b/PrismShareExtension/PrismShareExtension.entitlements
new file mode 100644
index 0000000..856fa09
--- /dev/null
+++ b/PrismShareExtension/PrismShareExtension.entitlements
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>com.apple.security.app-sandbox</key>
+ <true/>
+ <key>com.apple.security.application-groups</key>
+ <array>
+ <string>group.me.nore.ig.prism</string>
+ </array>
+</dict>
+</plist>
diff --git a/PrismShareExtension/Assets.xcassets/Contents.json b/PrismShareExtension/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/PrismShareExtension/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/PrismShareExtension/Assets.xcassets/AppIcon.appiconset b/PrismShareExtension/Assets.xcassets/AppIcon.appiconset
new file mode 120000
index 0000000..16b3e11
--- /dev/null
+++ b/PrismShareExtension/Assets.xcassets/AppIcon.appiconset
@@ -0,0 +1 @@
+../../prism/Assets.xcassets/AppIcon.appiconset
\ No newline at end of file
diff --git a/prism/Models/DocumentSource.swift b/prism/Models/DocumentSource.swift
index 9cb3461..69337c5 100644
--- a/prism/Models/DocumentSource.swift
+++ b/prism/Models/DocumentSource.swift
@@ -34,13 +34,20 @@ enum DocumentSource: Equatable {
/// - display: The original URL the user provided (used for identity and display).
case url(remote: URL, display: URL)
+ /// Content received via the Share Extension.
+ ///
+ /// Backed by a transient JSON file in the App Group container that is
+ /// deleted as soon as the main app loads it. The session has no stable
+ /// identifier and is never added to the recent files list.
+ case shared(filename: String)
+
/// Whether this source represents unsaved content.
///
- /// File-based and bundled documents are considered saved,
+ /// File-based, bundled, URL, and shared documents are considered saved,
/// while clipboard content is unsaved until explicitly saved by the user.
var isUnsaved: Bool {
switch self {
- case .file, .bundled, .url: return false
+ case .file, .bundled, .url, .shared: return false
case .clipboard: return true
}
}
@@ -49,7 +56,7 @@ enum DocumentSource: Equatable {
var isFile: Bool {
switch self {
case .file: return true
- case .clipboard, .bundled, .url: return false
+ case .clipboard, .bundled, .url, .shared: return false
}
}
@@ -57,7 +64,7 @@ enum DocumentSource: Equatable {
var isBundled: Bool {
switch self {
case .bundled: return true
- case .file, .clipboard, .url: return false
+ case .file, .clipboard, .url, .shared: return false
}
}
@@ -65,19 +72,21 @@ enum DocumentSource: Equatable {
var isURL: Bool {
switch self {
case .url: return true
- case .file, .clipboard, .bundled: return false
+ case .file, .clipboard, .bundled, .shared: return false
}
}
/// Whether this source supports user notes.
///
- /// All document sources support notes. File-based and bundled documents
- /// use stable URLs for persistence. Clipboard content uses a session-based
- /// identifier via `NotesManager.loadNotes(source:sessionID:blocks:)`.
+ /// File-based, bundled, and URL documents support notes via stable
+ /// identifiers; clipboard content uses a session-based identifier.
+ /// Shared documents are ephemeral and have no stable identifier, so
+ /// notes are unsupported.
var supportsNotes: Bool {
- // All current source types support notes. If a new case is added
- // that should not support notes, replace this with an explicit switch.
- true
+ switch self {
+ case .file, .clipboard, .bundled, .url: return true
+ case .shared: return false
+ }
}
/// Display title for the navigation bar.
@@ -91,6 +100,7 @@ enum DocumentSource: Equatable {
case .clipboard: return "Untitled"
case .bundled(let name): return BundledDocument.displayName(for: name)
case .url(_, let display): return Self.urlDisplayTitle(for: display)
+ case .shared(let filename): return filename
}
}
@@ -104,6 +114,7 @@ enum DocumentSource: Equatable {
case .clipboard: return "Untitled •"
case .bundled(let name): return BundledDocument.displayName(for: name)
case .url(_, let display): return Self.urlDisplayTitle(for: display)
+ case .shared(let filename): return filename
}
}
@@ -115,7 +126,7 @@ enum DocumentSource: Equatable {
var url: URL? {
switch self {
case .file(let url): return url
- case .clipboard: return nil
+ case .clipboard, .shared: return nil
case .bundled(let name): return BundledDocument.url(for: name)
case .url(_, let display): return display
}
@@ -138,13 +149,16 @@ enum DocumentSource: Equatable {
/// Simplified source type for image resolution (avoids exposing associated values).
enum DocumentSourceType: Sendable {
- case file, url, clipboard, bundled
+ case file, url, clipboard, bundled, shared
}
// MARK: - Image Resolution Support
extension DocumentSource {
/// The base URL for resolving relative image paths.
+ ///
+ /// Returns nil for `.clipboard` and `.shared` — relative image paths are
+ /// unresolvable for those sources (Req 4.5 for `.shared`).
var imageBaseURL: URL? {
switch self {
case .file(let url):
@@ -156,7 +170,7 @@ extension DocumentSource {
return bundleURL.deletingLastPathComponent()
}
return Bundle.main.resourceURL
- case .clipboard:
+ case .clipboard, .shared:
return nil
}
}
@@ -168,6 +182,7 @@ extension DocumentSource {
case .url: return .url
case .bundled: return .bundled
case .clipboard: return .clipboard
+ case .shared: return .shared
}
}
}
diff --git a/prism/Models/DocumentSession.swift b/prism/Models/DocumentSession.swift
index 39aeb7b..d737001 100644
--- a/prism/Models/DocumentSession.swift
+++ b/prism/Models/DocumentSession.swift
@@ -280,6 +280,26 @@ final class DocumentSession: Identifiable {
wireSearchClosures()
}
+ /// Creates a session from content delivered by `PrismShareExtension`.
+ ///
+ /// Shared sessions are ephemeral — the backing JSON file in the App
+ /// Group container has already been (or is about to be) deleted by
+ /// `DocumentFlowCoordinator`. No `FileChangeObserver` is created
+ /// because there is no on-disk file to observe, and the session is
+ /// not persisted via `StatePersistence` (`toPersistableState()`
+ /// already returns nil for non-clipboard sources).
+ ///
+ /// - Parameters:
+ /// - sharedContent: The markdown content extracted from the shared payload.
+ /// - filename: The original filename, used as the document's display title.
+ init(sharedContent: String, filename: String) {
+ self.id = UUID()
+ self.source = .shared(filename: filename)
+ self.content = sharedContent
+ self.fileObserver = nil
+ wireSearchClosures()
+ }
+
/// Whether this session has unsaved content.
///
/// Returns true for clipboard sources, false for file sources.
@@ -581,7 +601,11 @@ final class DocumentSession: Identifiable {
}
/// Returns the stable document identifier for this session's source,
- /// or nil for clipboard sessions (which use `StatePersistence` instead).
+ /// or nil for clipboard and shared sessions.
+ ///
+ /// Clipboard sessions use `StatePersistence` instead. Shared sessions
+ /// are ephemeral — the backing JSON file is deleted as soon as the main
+ /// app loads it, so there is no stable identity to persist against.
private func resolvedDocumentIdentifier() -> String? {
let resolver = DocumentIdentifierResolver()
switch source {
@@ -591,7 +615,7 @@ final class DocumentSession: Identifiable {
return resolver.resolve(forBundledResource: name).path
case .url(_, let displayURL):
return resolver.resolve(forRemoteURL: displayURL).path
- case .clipboard:
+ case .clipboard, .shared:
return nil
}
}
diff --git a/prism/Models/SharedContentPayload.swift b/prism/Models/SharedContentPayload.swift
new file mode 100644
index 0000000..2c9ef53
--- /dev/null
+++ b/prism/Models/SharedContentPayload.swift
@@ -0,0 +1,58 @@
+//
+// SharedContentPayload.swift
+// prism
+//
+// Codable payload exchanged between PrismShareExtension and the main app
+// via the App Group container (T-431, Decision 12).
+//
+
+import Foundation
+
+/// Single-file payload exchanged through the App Group shared container.
+///
+/// The Share Extension writes one `{uuid}.json` per share into
+/// `group.me.nore.ig.prism/shared-content/`. The main app reads it back
+/// through `SharedContentManager` and deletes the file after the document
+/// has been loaded. Embedding the markdown content and its metadata in a
+/// single JSON file makes the handoff atomic — there is no partial-write
+/// window where the content exists without its metadata sidecar
+/// (Decision 12 supersedes Decision 9).
+///
+/// This struct is intentionally duplicated in the `PrismShareExtension`
+/// target. Both copies must stay byte-compatible: the field names form
+/// the on-wire JSON contract.
+nonisolated struct SharedContentPayload: Codable, Equatable, Sendable {
+ /// The filename of the source file, used as the document's display title.
+ let originalFilename: String
+
+ /// When the extension wrote the payload. Used by the cleanup sweep to
+ /// distinguish very recent files (still being written by the extension)
+ /// from stale ones older than 24 hours.
+ let createdAt: Date
+
+ /// The markdown body. Up to 10MB after UTF-8 decoding (enforced by the
+ /// extension at write time).
+ let content: String
+
+ // MARK: - Wire format
+
+ /// Shared `JSONEncoder` for writing payloads to the App Group container.
+ ///
+ /// Both `SharedContentManager` (main app, reads) and
+ /// `SharedContentWriter` (extension, writes) MUST use this factory so
+ /// the date format stays in sync. ISO-8601 is chosen over the default
+ /// `.deferredToDate` so payloads are human-readable when inspecting
+ /// the container during debugging.
+ nonisolated static func makeEncoder() -> JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ return encoder
+ }
+
+ /// Shared `JSONDecoder` paired with `makeEncoder()`.
+ nonisolated static func makeDecoder() -> JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return decoder
+ }
+}
diff --git a/prism/Models/SharedContentError.swift b/prism/Models/SharedContentError.swift
new file mode 100644
index 0000000..f012657
--- /dev/null
+++ b/prism/Models/SharedContentError.swift
@@ -0,0 +1,45 @@
+//
+// SharedContentError.swift
+// prism
+//
+// Errors raised by SharedContentManager / SharedContentWriter when
+// exchanging data with PrismShareExtension via the App Group container.
+//
+
+import Foundation
+
+/// Errors raised during App Group container operations for the Share
+/// Extension handoff (T-431).
+///
+/// The extension and the main app both touch the same shared container;
+/// the same error type covers both write and read paths so localised
+/// messages stay in one place. The strings are also reused by the
+/// extension UI (Reqs 8.2, 8.3).
+enum SharedContentError: Error, LocalizedError, Equatable {
+ /// `FileManager.containerURL(forSecurityApplicationGroupIdentifier:)`
+ /// returned nil — typically a missing entitlement, an unprovisioned
+ /// App Group identifier, or sandbox restriction.
+ case appGroupUnavailable
+
+ /// Encoding `SharedContentPayload` or writing the resulting file to
+ /// the shared container failed.
+ case writeFailure
+
+ /// The expected `{uuid}.json` file is missing from the shared
+ /// container (already cleaned up, or never written).
+ case contentNotFound
+
+ var errorDescription: String? {
+ switch self {
+ case .appGroupUnavailable:
+ return String(localized: "error.sharedContent.appGroupUnavailable",
+ defaultValue: "Could not access shared storage")
+ case .writeFailure:
+ return String(localized: "error.sharedContent.writeFailure",
+ defaultValue: "Could not save shared content")
+ case .contentNotFound:
+ return String(localized: "error.sharedContent.contentNotFound",
+ defaultValue: "Shared content not found")
+ }
+ }
+}
diff --git a/prism/Services/SharedContentManager.swift b/prism/Services/SharedContentManager.swift
new file mode 100644
index 0000000..6ea32de
--- /dev/null
+++ b/prism/Services/SharedContentManager.swift
@@ -0,0 +1,145 @@
+//
+// SharedContentManager.swift
+// prism
+//
+// Reads and cleans the App Group shared container used by
+// PrismShareExtension to hand off markdown content (T-431).
+//
+
+import Foundation
+
+/// Reads and cleans the App Group shared container that PrismShareExtension
+/// writes shared markdown payloads into.
+///
+/// Each share produces one `{uuid}.json` inside
+/// `group.me.nore.ig.prism/shared-content/` containing a
+/// `SharedContentPayload` (filename, timestamp, content). The main app
+/// `DocumentFlowCoordinator` resolves the UUID from a
+/// `prism://open-shared?id=…` URL, calls `load(id:)` to read the payload,
+/// then calls `delete(id:)` after successfully creating a session for it
+/// (Reqs 4.1, 4.2, 4.6, 4.8, 5.2, 5.3, 5.5, 5.8).
+///
+/// Path traversal is prevented by construction — the UUID has already been
+/// parsed via `UUID(uuidString:)` before we touch the filesystem, so the
+/// path components are limited to hex digits and dashes.
+///
+/// Stored as an `enum` with static API per the design. Tests substitute the
+/// real App Group container with a temp directory by setting
+/// `containerURLOverride` (see `SharedContentManagerTests`).
+enum SharedContentManager {
+ /// App Group identifier registered for the main app and the extension.
+ ///
+ /// Lowercased to match the identifier created via Xcode's automatic
+ /// developer-portal sync and the value present in both entitlements
+ /// files (see prerequisites/Decision 14).
+ nonisolated static let appGroupID = "group.me.nore.ig.prism"
+
+ /// Subdirectory inside the App Group container that holds shared
+ /// content payloads. Keeping shared payloads in their own subdirectory
+ /// keeps the root tidy if other features start using the same group.
+ nonisolated static let subdirectory = "shared-content"
+
+ #if DEBUG
+ /// Test-only override for the container URL.
+ ///
+ /// When non-nil, all filesystem operations target this directory
+ /// instead of the real App Group container. Stripped from release
+ /// builds so production code can never touch a stand-in container.
+ /// Marked `nonisolated(unsafe)` because Swift 6 strict concurrency
+ /// rejects mutable globals otherwise — tests run serialised and
+ /// assign before any concurrent access, so this is safe.
+ nonisolated(unsafe) static var containerURLOverride: URL?
+ #endif
+
+ /// Loads the shared payload for `id` from the App Group container.
+ ///
+ /// Returns `nil` if the file is missing, unreadable, or the JSON is
+ /// malformed — callers surface a `loadError` (Req 4.8) on `nil`.
+ ///
+ /// `nonisolated` so cleanup can run from a background `Task.detached`
+ /// (see prismApp.swift) without hopping back to MainActor.
+ nonisolated static func load(id: UUID) -> SharedContentPayload? {
+ guard let fileURL = fileURL(for: id),
+ let data = try? Data(contentsOf: fileURL),
+ let payload = try? SharedContentPayload.makeDecoder().decode(SharedContentPayload.self, from: data)
+ else { return nil }
+ return payload
+ }
+
+ /// Deletes the shared payload file for `id`.
+ ///
+ /// Called after a successful load so the same UUID can't be reused
+ /// and stale payloads don't accumulate (Req 4.6, 5.5). Silently
+ /// succeeds if the file is already gone — the caller doesn't care.
+ nonisolated static func delete(id: UUID) {
+ guard let fileURL = fileURL(for: id) else { return }
+ try? FileManager.default.removeItem(at: fileURL)
+ }
+
+ /// Removes payloads older than 24 hours from the App Group container.
+ ///
+ /// Skips files created within the last 60 seconds so we don't race a
+ /// share that is mid-write — the extension's atomic write should
+ /// already protect against partial reads, but the 60-second guard is
+ /// cheap belt-and-braces (Req 5.6). Files without a readable
+ /// `creationDate` resource value are left alone.
+ ///
+ /// The main app calls this with a small delay on launch so a pending
+ /// `prism://open-shared` URL has had a chance to fire before cleanup
+ /// sweeps anything (see `prismApp` integration in Phase 3).
+ ///
+ /// `nonisolated` so the prismApp `.task` can dispatch it onto a
+ /// background `Task.detached` without paying MainActor hops.
+ nonisolated static func cleanupStaleFiles() {
+ guard let containerURL = sharedContainerURL() else { return }
+
+ let now = Date()
+ let staleCutoff = now.addingTimeInterval(-24 * 60 * 60)
+ let recentThreshold = now.addingTimeInterval(-60)
+ let fileManager = FileManager.default
+
+ guard let contents = try? fileManager.contentsOfDirectory(
+ at: containerURL,
+ includingPropertiesForKeys: [.creationDateKey],
+ options: [.skipsHiddenFiles]
+ ) else { return }
+
+ for fileURL in contents {
+ guard let values = try? fileURL.resourceValues(forKeys: [.creationDateKey]),
+ let created = values.creationDate else { continue }
+
+ // Race guard: leave files that may still be receiving writes.
+ guard created < recentThreshold else { continue }
+
+ if created < staleCutoff {
+ try? fileManager.removeItem(at: fileURL)
+ }
+ }
+ }
+
+ // MARK: - Internals
+
+ /// URL of the App Group container subdirectory, creating it if missing.
+ ///
+ /// Honours `containerURLOverride` for tests; otherwise resolves the
+ /// real App Group container. Returns `nil` if the group isn't
+ /// available (missing entitlement, unprovisioned, sandbox-blocked).
+ nonisolated private static func sharedContainerURL() -> URL? {
+ #if DEBUG
+ if let override = containerURLOverride {
+ try? FileManager.default.createDirectory(at: override, withIntermediateDirectories: true)
+ return override
+ }
+ #endif
+ guard let containerURL = FileManager.default.containerURL(
+ forSecurityApplicationGroupIdentifier: appGroupID
+ ) else { return nil }
+ let directoryURL = containerURL.appendingPathComponent(subdirectory)
+ try? FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
+ return directoryURL
+ }
+
+ nonisolated private static func fileURL(for id: UUID) -> URL? {
+ sharedContainerURL()?.appendingPathComponent("\(id.uuidString).json")
+ }
+}
diff --git a/prism/Services/ImagePathResolver.swift b/prism/Services/ImagePathResolver.swift
index e7021d8..5d75590 100644
--- a/prism/Services/ImagePathResolver.swift
+++ b/prism/Services/ImagePathResolver.swift
@@ -215,7 +215,7 @@ enum ImagePathResolver {
switch sourceType {
case .file, .bundled:
return resolvedSource(url: url, asLocal: true)
- case .url, .clipboard:
+ case .url, .clipboard, .shared:
return .failed(.blockedScheme)
}
}
@@ -235,8 +235,8 @@ enum ImagePathResolver {
baseURL: URL?,
sourceType: DocumentSourceType
) -> ResolvedImageSource {
- // Clipboard source with relative path has no context
- if sourceType == .clipboard {
+ // Clipboard and shared sources have no base URL for relative paths.
+ if sourceType == .clipboard || sourceType == .shared {
return .failed(.noContext)
}
@@ -275,7 +275,7 @@ enum ImagePathResolver {
return .failed(.invalidURL)
}
return resolvedSource(url: resolved.standardized, asLocal: false)
- case .clipboard:
+ case .clipboard, .shared:
return .failed(.noContext)
}
}
@@ -315,7 +315,7 @@ enum ImagePathResolver {
}
return resolvedSource(url: resolved.absoluteURL.standardized, asLocal: false)
- case .clipboard:
+ case .clipboard, .shared:
return .failed(.noContext)
}
}
diff --git a/prism/Services/LinkPathResolver.swift b/prism/Services/LinkPathResolver.swift
index 7bdb4ea..f822d23 100644
--- a/prism/Services/LinkPathResolver.swift
+++ b/prism/Services/LinkPathResolver.swift
@@ -100,8 +100,8 @@ enum LinkPathResolver {
switch sourceType {
case .file, .bundled:
return classifyLocal(cleanURL, fragment: fragment)
- case .url, .clipboard:
- // Clipboard has no base URL context; file:// links can't resolve.
+ case .url, .clipboard, .shared:
+ // Clipboard and shared have no base URL context; file:// links can't resolve.
return .unresolvable
}
}
@@ -121,7 +121,7 @@ enum LinkPathResolver {
baseURL: URL?,
sourceType: DocumentSourceType
) -> ResolvedLink {
- if sourceType == .clipboard {
+ if sourceType == .clipboard || sourceType == .shared {
return .unresolvable
}
@@ -159,8 +159,8 @@ enum LinkPathResolver {
return .unresolvable
}
return classifyRemote(resolved.standardized, fragment: fragmentPart)
- case .clipboard:
- // Unreachable: early return on line 119 handles clipboard before this point.
+ case .clipboard, .shared:
+ // Unreachable: early return above handles these before this point.
return .unresolvable
}
}
@@ -202,8 +202,8 @@ enum LinkPathResolver {
}
return classifyRemote(resolved.absoluteURL.standardized, fragment: fragment)
- case .clipboard:
- // Unreachable: early return on line 119 handles clipboard before this point.
+ case .clipboard, .shared:
+ // Unreachable: early return above handles these before this point.
return .unresolvable
}
}
diff --git a/prism/Services/NotesManager.swift b/prism/Services/NotesManager.swift
index 736f737..1b1cc6b 100644
--- a/prism/Services/NotesManager.swift
+++ b/prism/Services/NotesManager.swift
@@ -144,6 +144,12 @@ final class NotesManager {
identifierResolver.resolve(forRemoteURL: displayURL),
DocumentSource.urlDisplayTitle(for: displayURL)
)
+ case .shared(let filename):
+ // Shared sources have `supportsNotes == false`; callers should
+ // skip notes for them. If a caller reaches here anyway, scope the
+ // identifier to the session so any accidental writes stay
+ // confined to this transient document.
+ return (identifierResolver.resolve(forClipboardSession: sessionID), filename)
}
}
diff --git a/prism/ViewModels/DocumentFlowCoordinator.swift b/prism/ViewModels/DocumentFlowCoordinator.swift
index 49aefba..fd086c3 100644
--- a/prism/ViewModels/DocumentFlowCoordinator.swift
+++ b/prism/ViewModels/DocumentFlowCoordinator.swift
@@ -118,6 +118,14 @@ final class DocumentFlowCoordinator {
let urlParam = components.queryItems?.first(where: { $0.name == "url" })?.value,
let remoteURL = URL(string: urlParam) {
requestOpenRemoteURL(remoteURL)
+ } else if scheme == "prism", url.host == "open-shared",
+ let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
+ let idString = components.queryItems?.first(where: { $0.name == "id" })?.value,
+ let id = UUID(uuidString: idString) {
+ // PrismShareExtension handoff (T-431). UUID parsing here is the
+ // only line of defence against path traversal — any malformed id
+ // is rejected before SharedContentManager touches the filesystem.
+ requestOpenSharedDocument(id: id)
} else if scheme == "http" || scheme == "https" {
requestOpenRemoteURL(url)
} else if url.isFileURL {
@@ -181,6 +189,18 @@ final class DocumentFlowCoordinator {
}
}
+ /// Requests to open content delivered by `PrismShareExtension`,
+ /// showing the unsaved-content confirmation if a clipboard session is
+ /// currently open (Req 4.9).
+ func requestOpenSharedDocument(id: UUID) {
+ if currentSession?.isUnsaved == true {
+ pendingAction = .openShared(id: id)
+ showUnsavedConfirmation = true
+ } else {
+ openSharedDocument(id: id)
+ }
+ }
+
// MARK: - Confirmation Handlers
/// Handles "Save" selection in confirmation dialog.
@@ -211,6 +231,8 @@ final class DocumentFlowCoordinator {
remoteCoordinator?.openRemoteURL(url, fragment: fragment, flowCoordinator: self)
case .close:
closeDocument()
+ case .openShared(let id):
+ openSharedDocument(id: id)
}
}
@@ -389,6 +411,52 @@ final class DocumentFlowCoordinator {
}
}
+ // MARK: - Shared Content Operations
+
+ /// Loads a payload written by `PrismShareExtension` and activates it.
+ ///
+ /// Reads `{uuid}.json` from the App Group container, creates a
+ /// `.shared` `DocumentSession`, navigates to it, then deletes the
+ /// payload so it can't be reopened (Reqs 4.1–4.6, 4.8). Surfaces
+ /// `loadError` if the payload is missing or unreadable (Req 4.8).
+ /// On macOS, brings the app to the foreground in case the share
+ /// sheet activated us from another app (Req 4.10).
+ ///
+ /// Shared sessions are not added to the recent files list (Req 4.4)
+ /// — the file is gone the moment we finish loading it, so there is
+ /// nothing for "recent" to point at.
+ func openSharedDocument(id: UUID) {
+ loadError = nil
+
+ guard let payload = SharedContentManager.load(id: id) else {
+ // `SharedContentError.contentNotFound` carries the user-facing
+ // string we want here; reusing it keeps the message in one
+ // place and the catalog key (`error.sharedContent.contentNotFound`)
+ // referenced from a single source.
+ loadError = SharedContentError.contentNotFound.errorDescription
+ return
+ }
+
+ // Cancel any in-flight remote download — same reasoning as in
+ // openFile(at:). A late-arriving response must not overwrite the
+ // shared session we are about to activate.
+ remoteCoordinator?.cancelDownload()
+
+ let session = DocumentSession(
+ sharedContent: payload.content,
+ filename: payload.originalFilename
+ )
+ activateSession(session)
+
+ // Delete *after* the session is activated. Even if delete fails,
+ // the cleanup sweep will eventually remove the file (Req 5.6).
+ SharedContentManager.delete(id: id)
+
+ #if os(macOS)
+ NSApplication.shared.activate()
+ #endif
+ }
+
// MARK: - Clipboard Operations
/// Pastes content from clipboard and creates a session.
diff --git a/prism/Views/DocumentReaderView.swift b/prism/Views/DocumentReaderView.swift
index 72078ab..7677912 100644
--- a/prism/Views/DocumentReaderView.swift
+++ b/prism/Views/DocumentReaderView.swift
@@ -263,7 +263,7 @@ struct DocumentReaderView: View {
recentFilesManager.updateTitle(for: url, title: session.cachedDocumentTitle)
case .url(_, let displayURL):
recentFilesManager.updateTitle(forRemoteURL: displayURL, title: session.cachedDocumentTitle)
- case .clipboard, .bundled:
+ case .clipboard, .bundled, .shared:
break
}
}
diff --git a/prism/Views/ImageDetailWindow.swift b/prism/Views/ImageDetailWindow.swift
index 18dfd09..519cbc2 100644
--- a/prism/Views/ImageDetailWindow.swift
+++ b/prism/Views/ImageDetailWindow.swift
@@ -450,6 +450,7 @@ extension DocumentSourceType {
case "url": return .url
case "clipboard": return .clipboard
case "bundled": return .bundled
+ case "shared": return .shared
default: return .clipboard
}
}
@@ -461,6 +462,7 @@ extension DocumentSourceType {
case .url: return "url"
case .clipboard: return "clipboard"
case .bundled: return "bundled"
+ case .shared: return "shared"
}
}
}
diff --git a/prism/prismApp.swift b/prism/prismApp.swift
index b432123..f0b9643 100644
--- a/prism/prismApp.swift
+++ b/prism/prismApp.swift
@@ -376,6 +376,26 @@ struct MainContentView: View {
}
}
.onOpenURL(perform: flowCoordinator.handleOpenURL)
+ .task {
+ // Sweep stale Share Extension payloads (T-431, Req 5.6).
+ //
+ // The 2-second delay gives `onOpenURL` a chance to fire first
+ // for any pending `prism://open-shared` URL so the cleanup
+ // never deletes a payload the user is actively opening. The
+ // 60-second skip window inside `cleanupStaleFiles()` is the
+ // hard belt-and-braces guard; this delay is the cheap
+ // suspenders.
+ //
+ // The cleanup itself is filesystem I/O and runs on a detached
+ // background task so it never blocks the main actor — on
+ // macOS this `.task` is re-issued per `MainContentView`
+ // lifetime (i.e. per window), so keeping the sweep cheap and
+ // off-main matters.
+ try? await Task.sleep(for: .seconds(2))
+ await Task.detached(priority: .background) {
+ SharedContentManager.cleanupStaleFiles()
+ }.value
+ }
#if os(macOS)
// T-432: accept markdown URLs dropped on the window.
// URL.self Transferable only validates that the payload is a URL,
@@ -531,6 +551,10 @@ enum PendingAction: Equatable {
/// User was trying to close the document
case close
+
+ /// User shared a markdown file via PrismShareExtension — load the
+ /// payload identified by `id` from the App Group container (T-431).
+ case openShared(id: UUID)
}
// MARK: - WindowActions (FocusedValue for menu commands)
diff --git a/prism/prism.entitlements b/prism/prism.entitlements
index c987495..65c4d6f 100644
--- a/prism/prism.entitlements
+++ b/prism/prism.entitlements
@@ -16,6 +16,10 @@
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
+ <key>com.apple.security.application-groups</key>
+ <array>
+ <string>group.me.nore.ig.prism</string>
+ </array>
<key>com.apple.security.network.client</key>
<true/>
</dict>
diff --git a/prismTests/DocumentSourceSharedTests.swift b/prismTests/DocumentSourceSharedTests.swift
new file mode 100644
index 0000000..73d223d
--- /dev/null
+++ b/prismTests/DocumentSourceSharedTests.swift
@@ -0,0 +1,71 @@
+//
+// DocumentSourceSharedTests.swift
+// prismTests
+//
+// Covers the `.shared` case added for the Share Extension feature (T-431).
+//
+
+import Foundation
+import Testing
+@testable import prism
+
+@Suite("DocumentSource .shared tests")
+struct DocumentSourceSharedTests {
+
+ private let filename = "Notes.md"
+
+ private var sharedSource: DocumentSource {
+ .shared(filename: filename)
+ }
+
+ @Test("isUnsaved returns false for .shared")
+ func testIsUnsavedReturnsFalse() {
+ #expect(sharedSource.isUnsaved == false)
+ }
+
+ @Test("isFile returns false for .shared")
+ func testIsFileReturnsFalse() {
+ #expect(sharedSource.isFile == false)
+ }
+
+ @Test("isBundled returns false for .shared")
+ func testIsBundledReturnsFalse() {
+ #expect(sharedSource.isBundled == false)
+ }
+
+ @Test("isURL returns false for .shared")
+ func testIsURLReturnsFalse() {
+ #expect(sharedSource.isURL == false)
+ }
+
+ @Test("supportsNotes returns false for .shared")
+ func testSupportsNotesReturnsFalse() {
+ #expect(sharedSource.supportsNotes == false)
+ }
+
+ @Test("displayTitle returns the original filename")
+ func testDisplayTitleReturnsFilename() {
+ #expect(sharedSource.displayTitle == filename)
+ }
+
+ @Test("displayTitleWithIndicator returns the filename without a bullet")
+ func testDisplayTitleWithIndicatorReturnsFilenameWithoutBullet() {
+ #expect(sharedSource.displayTitleWithIndicator == filename)
+ #expect(sharedSource.displayTitleWithIndicator.contains("•") == false)
+ }
+
+ @Test("url returns nil for .shared")
+ func testURLReturnsNil() {
+ #expect(sharedSource.url == nil)
+ }
+
+ @Test("imageBaseURL returns nil for .shared (Req 4.5)")
+ func testImageBaseURLReturnsNil() {
+ #expect(sharedSource.imageBaseURL == nil)
+ }
+
+ @Test("imageSourceType returns .shared")
+ func testImageSourceTypeReturnsShared() {
+ #expect(sharedSource.imageSourceType == .shared)
+ }
+}
diff --git a/prismTests/DocumentSessionSharedInitTests.swift b/prismTests/DocumentSessionSharedInitTests.swift
new file mode 100644
index 0000000..55417ee
--- /dev/null
+++ b/prismTests/DocumentSessionSharedInitTests.swift
@@ -0,0 +1,61 @@
+//
+// DocumentSessionSharedInitTests.swift
+// prismTests
+//
+// Covers the `DocumentSession(sharedContent:filename:)` initializer added
+// for the Share Extension feature (T-431).
+//
+
+import Foundation
+import Testing
+@testable import prism
+
+@Suite("DocumentSession shared content init")
+@MainActor
+struct DocumentSessionSharedInitTests {
+
+ private let filename = "Notes.md"
+ private let content = "# Shared from extension\n\nBody."
+
+ @Test("source is .shared(filename:)")
+ func testSourceIsShared() {
+ let session = DocumentSession(sharedContent: content, filename: filename)
+ if case .shared(let sharedFilename) = session.source {
+ #expect(sharedFilename == filename)
+ } else {
+ Issue.record("Expected .shared source, got \(session.source)")
+ }
+ }
+
+ @Test("content is set to the shared payload body")
+ func testContentMatches() {
+ let session = DocumentSession(sharedContent: content, filename: filename)
+ #expect(session.content == content)
+ }
+
+ @Test("fileObserver is nil for shared sessions")
+ func testFileObserverIsNil() {
+ let session = DocumentSession(sharedContent: content, filename: filename)
+ #expect(session.fileObserver == nil)
+ }
+
+ @Test("isUnsaved returns false")
+ func testIsUnsavedFalse() {
+ let session = DocumentSession(sharedContent: content, filename: filename)
+ #expect(session.isUnsaved == false)
+ }
+
+ @Test("toPersistableState() returns nil (shared sessions are ephemeral)")
+ func testToPersistableStateReturnsNil() {
+ let session = DocumentSession(sharedContent: content, filename: filename)
+ #expect(session.toPersistableState() == nil)
+ }
+
+ @Test("navigationTitle falls back to the filename when no H1 present")
+ func testNavigationTitleFallsBackToFilename() {
+ // The session hasn't been parsed yet — `cachedDocumentTitle` is nil,
+ // so the source's `displayTitleWithIndicator` (the filename) takes over.
+ let session = DocumentSession(sharedContent: "no heading here", filename: filename)
+ #expect(session.navigationTitle == filename)
+ }
+}
diff --git a/prismTests/SharedContentPayloadTests.swift b/prismTests/SharedContentPayloadTests.swift
new file mode 100644
index 0000000..1f3a483
--- /dev/null
+++ b/prismTests/SharedContentPayloadTests.swift
@@ -0,0 +1,74 @@
+//
+// SharedContentPayloadTests.swift
+// prismTests
+//
+// Codable round-trip coverage for SharedContentPayload (T-431).
+//
+
+import Foundation
+import Testing
+@testable import prism
+
+@Suite("SharedContentPayload Codable tests")
+struct SharedContentPayloadTests {
+
+ @Test("Encodes and decodes preserving all fields")
+ func testRoundtripPreservesFields() throws {
+ let createdAt = Date(timeIntervalSince1970: 1_715_000_000)
+ let payload = SharedContentPayload(
+ originalFilename: "Notes.md",
+ createdAt: createdAt,
+ content: "# Hello\n\nWorld."
+ )
+
+ let data = try SharedContentPayload.makeEncoder().encode(payload)
+ let decoded = try SharedContentPayload.makeDecoder().decode(SharedContentPayload.self, from: data)
+
+ #expect(decoded == payload)
+ }
+
+ @Test("JSON contains the documented field names")
+ func testJSONFieldNames() throws {
+ let payload = SharedContentPayload(
+ originalFilename: "Notes.md",
+ createdAt: Date(timeIntervalSince1970: 0),
+ content: "body"
+ )
+
+ let data = try SharedContentPayload.makeEncoder().encode(payload)
+ let json = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])
+
+ #expect(json["originalFilename"] as? String == "Notes.md")
+ #expect(json["content"] as? String == "body")
+ #expect(json["createdAt"] != nil)
+ }
+
+ @Test("Survives special characters in filename and content")
+ func testSpecialCharactersRoundtrip() throws {
+ let payload = SharedContentPayload(
+ originalFilename: "Réflexions — “quotes”.md",
+ createdAt: Date(timeIntervalSince1970: 1_715_000_001),
+ content: "Line 1\n\nEmoji 🚀, tab\there, backslash \\, quote \" and < > & control."
+ )
+
+ let data = try SharedContentPayload.makeEncoder().encode(payload)
+ let decoded = try SharedContentPayload.makeDecoder().decode(SharedContentPayload.self, from: data)
+
+ #expect(decoded == payload)
+ }
+
+ @Test("Empty content is preserved")
+ func testEmptyContentRoundtrip() throws {
+ let payload = SharedContentPayload(
+ originalFilename: "Empty.md",
+ createdAt: Date(timeIntervalSince1970: 1_715_000_002),
+ content: ""
+ )
+
+ let data = try SharedContentPayload.makeEncoder().encode(payload)
+ let decoded = try SharedContentPayload.makeDecoder().decode(SharedContentPayload.self, from: data)
+
+ #expect(decoded == payload)
+ #expect(decoded.content.isEmpty)
+ }
+}
diff --git a/prismTests/SharedContentManagerTests.swift b/prismTests/SharedContentManagerTests.swift
new file mode 100644
index 0000000..8b5b56e
--- /dev/null
+++ b/prismTests/SharedContentManagerTests.swift
@@ -0,0 +1,164 @@
+//
+// SharedContentManagerTests.swift
+// prismTests
+//
+// Covers SharedContentManager load/delete/cleanup behaviour using a
+// temporary directory in place of the real App Group container (T-431).
+//
+
+import Foundation
+import Testing
+@testable import prism
+
+@Suite("SharedContentManager tests", .serialized)
+struct SharedContentManagerTests {
+
+ /// Each test sets `containerURLOverride` so the real App Group container
+ /// is never touched. `.serialized` keeps these from racing each other on
+ /// the shared override.
+ private final class Sandbox {
+ let url: URL
+
+ init() {
+ let dir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("SharedContentManagerTests-\(UUID().uuidString)", isDirectory: true)
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ self.url = dir
+ SharedContentManager.containerURLOverride = dir
+ }
+
+ deinit {
+ SharedContentManager.containerURLOverride = nil
+ try? FileManager.default.removeItem(at: url)
+ }
+ }
+
+ private func writePayload(
+ _ payload: SharedContentPayload,
+ id: UUID,
+ in sandbox: Sandbox
+ ) throws -> URL {
+ let fileURL = sandbox.url.appendingPathComponent("\(id.uuidString).json")
+ let data = try SharedContentPayload.makeEncoder().encode(payload)
+ try data.write(to: fileURL, options: .atomic)
+ return fileURL
+ }
+
+ @Test("load reads a payload written to the container")
+ func testLoadRoundtrip() throws {
+ let sandbox = Sandbox()
+ let id = UUID()
+ let payload = SharedContentPayload(
+ originalFilename: "Notes.md",
+ createdAt: Date(timeIntervalSince1970: 1_715_000_000),
+ content: "# Hello"
+ )
+ _ = try writePayload(payload, id: id, in: sandbox)
+
+ let loaded = SharedContentManager.load(id: id)
+ #expect(loaded == payload)
+ }
+
+ @Test("load returns nil when the file is missing")
+ func testLoadReturnsNilForMissingFile() {
+ let sandbox = Sandbox()
+ let loaded = SharedContentManager.load(id: UUID())
+ #expect(loaded == nil)
+ // Sandbox kept alive by reference until test ends.
+ _ = sandbox
+ }
+
+ @Test("load returns nil when the file contents are not valid JSON")
+ func testLoadReturnsNilForCorruptJSON() throws {
+ let sandbox = Sandbox()
+ let id = UUID()
+ let fileURL = sandbox.url.appendingPathComponent("\(id.uuidString).json")
+ try Data("not valid json".utf8).write(to: fileURL)
+
+ let loaded = SharedContentManager.load(id: id)
+ #expect(loaded == nil)
+ }
+
+ @Test("delete removes the payload file")
+ func testDeleteRemovesFile() throws {
+ let sandbox = Sandbox()
+ let id = UUID()
+ let payload = SharedContentPayload(
+ originalFilename: "Notes.md",
+ createdAt: Date(timeIntervalSince1970: 1_715_000_000),
+ content: "body"
+ )
+ let fileURL = try writePayload(payload, id: id, in: sandbox)
+
+ SharedContentManager.delete(id: id)
+
+ #expect(FileManager.default.fileExists(atPath: fileURL.path) == false)
+ }
+
+ @Test("delete is a no-op when the file is already gone")
+ func testDeleteMissingFileIsNoOp() {
+ let sandbox = Sandbox()
+ SharedContentManager.delete(id: UUID())
+ // Reaching here without throwing is the expectation.
+ _ = sandbox
+ }
+
+ @Test("cleanupStaleFiles removes files older than 24 hours")
+ func testCleanupRemovesStaleFiles() throws {
+ let sandbox = Sandbox()
+ let id = UUID()
+ let payload = SharedContentPayload(
+ originalFilename: "Old.md",
+ createdAt: Date(),
+ content: "body"
+ )
+ let fileURL = try writePayload(payload, id: id, in: sandbox)
+
+ // Backdate the file's creation date to ~25 hours ago.
+ let staleDate = Date().addingTimeInterval(-25 * 60 * 60)
+ try (fileURL as NSURL).setResourceValue(staleDate, forKey: .creationDateKey)
+
+ SharedContentManager.cleanupStaleFiles()
+
+ #expect(FileManager.default.fileExists(atPath: fileURL.path) == false)
+ }
+
+ @Test("cleanupStaleFiles keeps files newer than 60 seconds")
+ func testCleanupSkipsRecentFiles() throws {
+ let sandbox = Sandbox()
+ let id = UUID()
+ let payload = SharedContentPayload(
+ originalFilename: "Fresh.md",
+ createdAt: Date(),
+ content: "body"
+ )
+ let fileURL = try writePayload(payload, id: id, in: sandbox)
+
+ // A file just written has its creation date "now", well within the
+ // 60-second race window. The 25-hour staleness check should not
+ // even be reached for this file.
+ SharedContentManager.cleanupStaleFiles()
+
+ #expect(FileManager.default.fileExists(atPath: fileURL.path))
+ }
+
+ @Test("cleanupStaleFiles keeps files between 60 seconds and 24 hours")
+ func testCleanupKeepsMidAgedFiles() throws {
+ let sandbox = Sandbox()
+ let id = UUID()
+ let payload = SharedContentPayload(
+ originalFilename: "MidAged.md",
+ createdAt: Date(),
+ content: "body"
+ )
+ let fileURL = try writePayload(payload, id: id, in: sandbox)
+
+ // 10 minutes ago: outside the race window but well within 24 hours.
+ let midDate = Date().addingTimeInterval(-10 * 60)
+ try (fileURL as NSURL).setResourceValue(midDate, forKey: .creationDateKey)
+
+ SharedContentManager.cleanupStaleFiles()
+
+ #expect(FileManager.default.fileExists(atPath: fileURL.path))
+ }
+}
diff --git a/prismTests/DocumentFlowCoordinatorSharedURLTests.swift b/prismTests/DocumentFlowCoordinatorSharedURLTests.swift
new file mode 100644
index 0000000..a1ca20b
--- /dev/null
+++ b/prismTests/DocumentFlowCoordinatorSharedURLTests.swift
@@ -0,0 +1,171 @@
+//
+// DocumentFlowCoordinatorSharedURLTests.swift
+// prismTests
+//
+// Covers PrismShareExtension URL routing (`prism://open-shared?id=…`)
+// added to `DocumentFlowCoordinator` for T-431.
+//
+
+import Foundation
+import Testing
+@testable import prism
+
+@Suite("DocumentFlowCoordinator open-shared routing", .serialized)
+@MainActor
+struct DocumentFlowCoordinatorSharedURLTests {
+
+ /// Test fixture that points `SharedContentManager` at a temp directory and
+ /// writes a payload at the requested UUID. Restores the override on deinit.
+ private final class Sandbox {
+ let url: URL
+ let id: UUID
+ let payload: SharedContentPayload
+
+ init(payload: SharedContentPayload = SharedContentPayload(
+ originalFilename: "Shared.md",
+ createdAt: Date(timeIntervalSince1970: 1_715_000_000),
+ content: "# From the extension"
+ )) {
+ let dir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("FlowCoordinatorSharedURLTests-\(UUID().uuidString)", isDirectory: true)
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ self.url = dir
+ self.id = UUID()
+ self.payload = payload
+ SharedContentManager.containerURLOverride = dir
+ let fileURL = dir.appendingPathComponent("\(id.uuidString).json")
+ let data = try? SharedContentPayload.makeEncoder().encode(payload)
+ try? data?.write(to: fileURL, options: .atomic)
+ }
+
+ deinit {
+ SharedContentManager.containerURLOverride = nil
+ try? FileManager.default.removeItem(at: url)
+ }
+ }
+
+ @Test("Routes prism://open-shared?id={valid-uuid} to the shared loader")
+ func testValidUUIDOpensSharedSession() {
+ let sandbox = Sandbox()
+ let flow = DocumentFlowCoordinator()
+ let url = URL(string: "prism://open-shared?id=\(sandbox.id.uuidString)")!
+
+ flow.handleOpenURL(url)
+
+ // openSharedDocument runs synchronously: a session is installed and
+ // the payload file is removed.
+ let installed = flow.currentSession
+ #expect(installed != nil)
+ if case .shared(let filename) = installed?.source {
+ #expect(filename == sandbox.payload.originalFilename)
+ } else {
+ Issue.record("Expected .shared source, got \(String(describing: installed?.source))")
+ }
+ #expect(installed?.content == sandbox.payload.content)
+
+ let payloadPath = sandbox.url.appendingPathComponent("\(sandbox.id.uuidString).json").path
+ #expect(FileManager.default.fileExists(atPath: payloadPath) == false,
+ "Payload file should be deleted after successful load (Req 4.6)")
+ }
+
+ @Test("Ignores prism://open-shared with an invalid UUID")
+ func testInvalidUUIDIsIgnored() {
+ let sandbox = Sandbox()
+ let flow = DocumentFlowCoordinator()
+ let url = URL(string: "prism://open-shared?id=not-a-uuid")!
+
+ flow.handleOpenURL(url)
+
+ #expect(flow.currentSession == nil)
+ #expect(flow.pendingAction == nil)
+ #expect(flow.showUnsavedConfirmation == false)
+ _ = sandbox
+ }
+
+ @Test("Ignores prism://open-shared with no id parameter")
+ func testMissingIDIsIgnored() {
+ let sandbox = Sandbox()
+ let flow = DocumentFlowCoordinator()
+ let url = URL(string: "prism://open-shared")!
+
+ flow.handleOpenURL(url)
+
+ #expect(flow.currentSession == nil)
+ #expect(flow.pendingAction == nil)
+ _ = sandbox
+ }
+
+ @Test("Surfaces loadError when the referenced payload is missing")
+ func testMissingPayloadProducesLoadError() {
+ let sandbox = Sandbox()
+ let flow = DocumentFlowCoordinator()
+
+ // Reference an unwritten UUID — the override is still in place but
+ // there's no corresponding file.
+ let missingID = UUID()
+ let url = URL(string: "prism://open-shared?id=\(missingID.uuidString)")!
+
+ flow.handleOpenURL(url)
+
+ #expect(flow.currentSession == nil)
+ #expect(flow.loadError != nil)
+ _ = sandbox
+ }
+
+ @Test("Unsaved clipboard session triggers confirmation rather than loading")
+ func testUnsavedSessionTriggersConfirmation() {
+ let sandbox = Sandbox()
+ let flow = DocumentFlowCoordinator()
+ flow.currentSession = DocumentSession(clipboardContent: "unsaved draft")
+ let url = URL(string: "prism://open-shared?id=\(sandbox.id.uuidString)")!
+
+ flow.handleOpenURL(url)
+
+ // The shared payload must NOT have been loaded — confirmation first.
+ #expect(flow.showUnsavedConfirmation == true)
+ if case .openShared(let pendingID) = flow.pendingAction {
+ #expect(pendingID == sandbox.id)
+ } else {
+ Issue.record("Expected .openShared pending action, got \(String(describing: flow.pendingAction))")
+ }
+ // The unsaved clipboard session is still the current one.
+ #expect(flow.currentSession?.source == .clipboard)
+ // Payload file still present until confirmation resolves.
+ let payloadPath = sandbox.url.appendingPathComponent("\(sandbox.id.uuidString).json").path
+ #expect(FileManager.default.fileExists(atPath: payloadPath))
+ }
+
+ @Test("handleDiscardAndProceed executes the pending openShared action")
+ func testDiscardProceedsWithOpenShared() {
+ let sandbox = Sandbox()
+ let flow = DocumentFlowCoordinator()
+ flow.currentSession = DocumentSession(clipboardContent: "unsaved draft")
+ let url = URL(string: "prism://open-shared?id=\(sandbox.id.uuidString)")!
+ flow.handleOpenURL(url)
+ #expect(flow.pendingAction != nil)
+
+ flow.handleDiscardAndProceed()
+
+ #expect(flow.pendingAction == nil)
+ if case .shared(let filename) = flow.currentSession?.source {
+ #expect(filename == sandbox.payload.originalFilename)
+ } else {
+ Issue.record("Expected .shared source after discard, got \(String(describing: flow.currentSession?.source))")
+ }
+ }
+
+ @Test("Existing prism://open?url= routing still works (Req 4.7)")
+ func testRemoteURLRoutingUnaffected() {
+ // We don't need a real loader here — just confirm the new branch
+ // doesn't intercept open?url=.
+ let flow = DocumentFlowCoordinator()
+ let url = URL(string: "prism://open?url=https://example.com/page.md")!
+
+ flow.handleOpenURL(url)
+
+ // Without a configured remoteCoordinator no download starts, but the
+ // route must not land in the shared loader.
+ #expect(flow.currentSession == nil)
+ #expect(flow.pendingAction == nil)
+ }
+}
diff --git a/prism.xcodeproj/project.pbxproj b/prism.xcodeproj/project.pbxproj
index 80fee3e..71773ab 100644
--- a/prism.xcodeproj/project.pbxproj
+++ b/prism.xcodeproj/project.pbxproj
@@ -10,6 +10,7 @@
D49C76B22F0D4D4400CE740B /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = D49C76942F0CC21000CE740B /* Markdown */; };
D49C76B42F0D5A0000CE740B /* HighlightSwift in Frameworks */ = {isa = PBXBuildFile; productRef = D49C76962F0D5A0000CE740B /* HighlightSwift */; };
D49C76B52F0E8A0000CE740B /* Textual in Frameworks */ = {isa = PBXBuildFile; productRef = D49C76982F0E8A0000CE740B /* Textual */; };
+ D4C79B302FBB3331003D33DB /* PrismShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D4C79B262FBB3331003D33DB /* PrismShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -27,13 +28,35 @@
remoteGlobalIDString = D49C76632F0CC12F00CE740B;
remoteInfo = prism;
};
+ D4C79B2E2FBB3331003D33DB /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = D49C765C2F0CC12F00CE740B /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = D4C79B252FBB3331003D33DB;
+ remoteInfo = PrismShareExtension;
+ };
/* End PBXContainerItemProxy section */
+/* Begin PBXCopyFilesBuildPhase section */
+ D4C79B352FBB3331003D33DB /* Embed Foundation Extensions */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 13;
+ files = (
+ D4C79B302FBB3331003D33DB /* PrismShareExtension.appex in Embed Foundation Extensions */,
+ );
+ name = "Embed Foundation Extensions";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
/* Begin PBXFileReference section */
D49C76642F0CC12F00CE740B /* prism.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = prism.app; sourceTree = BUILT_PRODUCTS_DIR; };
D49C76742F0CC13100CE740B /* prismTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = prismTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
D49C767E2F0CC13100CE740B /* prismUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = prismUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
D4A678752FA76E8A005E8A40 /* Products.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; name = Products.storekit; path = prism/Resources/Products.storekit; sourceTree = "<group>"; };
+ D4C79B262FBB3331003D33DB /* PrismShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = PrismShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -45,6 +68,13 @@
);
target = D49C76632F0CC12F00CE740B /* prism */;
};
+ D4C79B342FBB3331003D33DB /* Exceptions for "PrismShareExtension" folder in "PrismShareExtension" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = D4C79B252FBB3331003D33DB /* PrismShareExtension */;
+ };
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -66,6 +96,14 @@
path = prismUITests;
sourceTree = "<group>";
};
+ D4C79B272FBB3331003D33DB /* PrismShareExtension */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ D4C79B342FBB3331003D33DB /* Exceptions for "PrismShareExtension" folder in "PrismShareExtension" target */,
+ );
+ path = PrismShareExtension;
+ sourceTree = "<group>";
+ };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -93,6 +131,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ D4C79B232FBB3331003D33DB /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -103,6 +148,7 @@
D49C76662F0CC12F00CE740B /* prism */,
D49C76772F0CC13100CE740B /* prismTests */,
D49C76812F0CC13100CE740B /* prismUITests */,
+ D4C79B272FBB3331003D33DB /* PrismShareExtension */,
D49C76652F0CC12F00CE740B /* Products */,
);
sourceTree = "<group>";
@@ -113,6 +159,7 @@
D49C76642F0CC12F00CE740B /* prism.app */,
D49C76742F0CC13100CE740B /* prismTests.xctest */,
D49C767E2F0CC13100CE740B /* prismUITests.xctest */,
+ D4C79B262FBB3331003D33DB /* PrismShareExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -129,10 +176,12 @@
D49C76612F0CC12F00CE740B /* Frameworks */,
D49C76622F0CC12F00CE740B /* Resources */,
D4A678812FA80000005E8A40 /* Localisation: install compiled strings */,
+ D4C79B352FBB3331003D33DB /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
+ D4C79B2F2FBB3331003D33DB /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
D49C76662F0CC12F00CE740B /* prism */,
@@ -193,6 +242,28 @@
productReference = D49C767E2F0CC13100CE740B /* prismUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
+ D4C79B252FBB3331003D33DB /* PrismShareExtension */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = D4C79B312FBB3331003D33DB /* Build configuration list for PBXNativeTarget "PrismShareExtension" */;
+ buildPhases = (
+ D4C79B222FBB3331003D33DB /* Sources */,
+ D4C79B232FBB3331003D33DB /* Frameworks */,
+ D4C79B242FBB3331003D33DB /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ D4C79B272FBB3331003D33DB /* PrismShareExtension */,
+ );
+ name = PrismShareExtension;
+ packageProductDependencies = (
+ );
+ productName = PrismShareExtension;
+ productReference = D4C79B262FBB3331003D33DB /* PrismShareExtension.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -200,7 +271,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
- LastSwiftUpdateCheck = 2620;
+ LastSwiftUpdateCheck = 2640;
LastUpgradeCheck = 2620;
TargetAttributes = {
D49C76632F0CC12F00CE740B = {
@@ -214,6 +285,9 @@
CreatedOnToolsVersion = 26.2;
TestTargetID = D49C76632F0CC12F00CE740B;
};
+ D4C79B252FBB3331003D33DB = {
+ CreatedOnToolsVersion = 26.4.1;
+ };
};
};
buildConfigurationList = D49C765F2F0CC12F00CE740B /* Build configuration list for PBXProject "prism" */;
@@ -238,6 +312,7 @@
D49C76632F0CC12F00CE740B /* prism */,
D49C76732F0CC13100CE740B /* prismTests */,
D49C767D2F0CC13100CE740B /* prismUITests */,
+ D4C79B252FBB3331003D33DB /* PrismShareExtension */,
);
};
/* End PBXProject section */
@@ -264,6 +339,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ D4C79B242FBB3331003D33DB /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -348,6 +430,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ D4C79B222FBB3331003D33DB /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -361,6 +450,11 @@
target = D49C76632F0CC12F00CE740B /* prism */;
targetProxy = D49C767F2F0CC13100CE740B /* PBXContainerItemProxy */;
};
+ D4C79B2F2FBB3331003D33DB /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = D4C79B252FBB3331003D33DB /* PrismShareExtension */;
+ targetProxy = D4C79B2E2FBB3331003D33DB /* PBXContainerItemProxy */;
+ };
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@@ -690,6 +784,91 @@
};
name = Release;
};
+ D4C79B322FBB3331003D33DB /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = PrismShareExtension/PrismShareExtension.entitlements;
+ CODE_SIGN_IDENTITY = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = V24684SCZN;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = PrismShareExtension/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = PrismShareExtension;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ IPHONEOS_DEPLOYMENT_TARGET = 26.4;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MARKETING_VERSION = 0.10.0;
+ PRODUCT_BUNDLE_IDENTIFIER = me.nore.ig.prism.PrismShareExtension;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = auto;
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
+ SUPPORTS_MACCATALYST = NO;
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ D4C79B332FBB3331003D33DB /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = PrismShareExtension/PrismShareExtension.entitlements;
+ CODE_SIGN_IDENTITY = "Apple Development";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = V24684SCZN;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = PrismShareExtension/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = PrismShareExtension;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ IPHONEOS_DEPLOYMENT_TARGET = 26.4;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MARKETING_VERSION = 0.10.0;
+ PRODUCT_BUNDLE_IDENTIFIER = me.nore.ig.prism.PrismShareExtension;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = auto;
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
+ SUPPORTS_MACCATALYST = NO;
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -729,6 +908,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ D4C79B312FBB3331003D33DB /* Build configuration list for PBXNativeTarget "PrismShareExtension" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ D4C79B322FBB3331003D33DB /* Debug */,
+ D4C79B332FBB3331003D33DB /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
diff --git a/specs/share-extension/decision_log.md b/specs/share-extension/decision_log.md
index becada6..b786f0a 100644
--- a/specs/share-extension/decision_log.md
+++ b/specs/share-extension/decision_log.md
@@ -445,3 +445,141 @@ Dictionary-based rules are validated at install time, avoiding the silent TRUEPR
- Non-markdown files get an "Unsupported content type" error after selecting Prism
---
+
+## Decision 14: App Group identifier is lowercase `group.me.nore.ig.prism`
+
+**Date**: 2026-05-18
+**Status**: accepted
+
+### Context
+
+The requirements, design, and tasks documents specified the App Group as `group.me.nore.ig.Prism` (capital `P`) by analogy with the iCloud container identifiers (`iCloud.me.nore.ig.Prism`). When the App Group was actually registered via Xcode's automatic developer-portal sync during prerequisite setup, the identifier landed as `group.me.nore.ig.prism` (lowercase `p`). App Group identifiers are case-sensitive, so the implementation must match exactly what is registered in the developer portal and present in both entitlements files.
+
+### Decision
+
+Use `group.me.nore.ig.prism` (all lowercase) as the canonical App Group identifier across both targets, both entitlements files, `SharedContentManager`, and `SharedContentWriter`. The spec text in requirements/design/tasks that references `group.me.nore.ig.Prism` is treated as a documentation drift — this decision is the source of truth.
+
+### Rationale
+
+The identifier registered in the developer portal cannot be renamed without re-registering and reissuing provisioning profiles for both targets. The lowercase form is also more idiomatic for reverse-DNS-style identifiers and matches the bundle identifier root (`me.nore.ig.prism.*`). Updating the constant in code is a single-line change; renaming the developer-portal record is not. The cost asymmetry decides this in favour of lowercase.
+
+### Alternatives Considered
+
+- **Rename the developer-portal record to use capital `Prism`**: Requires manually deleting the existing App Group, recreating it, regenerating profiles for both targets, and risking a brief period where the main app's saved data (notes, recent files) is stranded in the old container. Rejected — high friction for purely cosmetic alignment with the spec text.
+- **Leave the constant as `group.me.nore.ig.Prism` and hope `containerURL(forSecurityApplicationGroupIdentifier:)` matches case-insensitively**: It does not; the call would return `nil` and the extension handoff would silently fail. Rejected.
+
+### Consequences
+
+**Positive:**
+- Implementation matches the actual provisioned App Group exactly.
+- Aligns with the bundle identifier root, which is also lowercase.
+- Zero migration risk for users.
+
+**Negative:**
+- Spec text in requirements.md, design.md, and tasks.md still shows the capital-`P` form; readers must consult this decision log entry to know the correct value. A documentation pass to rewrite those references is deferred.
+
+---
+
+## Decision 15: `SharedContentManager.containerURLOverride` is a DEBUG-only test seam
+
+**Date**: 2026-05-18
+**Status**: accepted
+
+### Context
+
+`SharedContentManager` is an `enum` with static API by design. Tests need to redirect filesystem operations away from the real App Group container — running against the actual container would either fail (no entitlement in the test host) or pollute the developer's machine. The design.md test plan ("Use a temporary directory to simulate the App Group container") was vague on the exact mechanism.
+
+### Decision
+
+Expose a `nonisolated(unsafe) static var containerURLOverride: URL?` on `SharedContentManager`, declared inside `#if DEBUG`. When non-nil it short-circuits `sharedContainerURL()` to the override path. The variable and the override branch in `sharedContainerURL()` are both `#if DEBUG`-gated so they are stripped from Release builds.
+
+### Rationale
+
+The codebase already uses this pattern — `DocumentFlowCoordinator.setRemoteCoordinatorForTests(_:)` (DocumentFlowCoordinator.swift) is similarly `#if DEBUG`-gated. Keeping consistency with the existing test-seam convention beats introducing a new protocol or struct just for one consumer. The `nonisolated(unsafe)` cost is bounded: the variable is only written by tests, the test suite is `.serialized` for cases that share it, and the override branch never runs in production. A protocol-based alternative would add an injected dependency to every call site of the static API for no production benefit.
+
+### Alternatives Considered
+
+- **`SharedContentStoring` protocol injected into `DocumentFlowCoordinator`**: cleaner separation but requires wiring through `configure(...)`, a new mock type for tests, and converts `SharedContentManager` from a static enum into a struct or class. Disproportionate for a single internal consumer.
+- **Closure-based factory at module init**: less invasive, but Swift has no module-init hook, so we would still need a global to hold the closure. Same `nonisolated(unsafe)` cost without the `#if DEBUG` safety.
+- **Test-time swizzling of `FileManager.containerURL(forSecurityApplicationGroupIdentifier:)`**: hostile to read.
+
+### Consequences
+
+**Positive:**
+- Tests run against a deterministic temp directory; no developer-portal entitlement required for unit tests.
+- Zero production-binary footprint — the variable and the override branch are stripped in Release builds.
+- Consistent with the existing `setRemoteCoordinatorForTests` test-seam pattern.
+
+**Negative:**
+- `nonisolated(unsafe)` opts out of Swift 6 strict concurrency checking; the safety contract (tests serialise, no concurrent access) is documented rather than enforced.
+
+---
+
+## Decision 16: Wire format pinned by `SharedContentPayload.makeEncoder()` / `makeDecoder()`
+
+**Date**: 2026-05-18
+**Status**: accepted
+
+### Context
+
+The design.md snippet for `SharedContentWriter` used `JSONEncoder().encode(payload)` and the snippet for `SharedContentManager.load(id:)` used `JSONDecoder().decode(...)`. Default `JSONEncoder` and `JSONDecoder` use `.deferredToDate` for `Date`, which encodes seconds-since-reference-date as a JSON number — fine, but opaque when inspecting payloads during debugging. More importantly, the two targets must agree on the strategy; if either drifts to a different default in a future Swift release, payloads silently fail to decode.
+
+### Decision
+
+Add `static func makeEncoder() -> JSONEncoder` and `static func makeDecoder() -> JSONDecoder` factory methods on `SharedContentPayload` (duplicated in both target copies of the type, identical implementations). Both pin `dateEncodingStrategy = .iso8601` / `dateDecodingStrategy = .iso8601`. `SharedContentWriter` (extension) and `SharedContentManager` (main app) — and all corresponding tests — use these factories exclusively.
+
+### Rationale
+
+Colocating the encoder/decoder configuration with the type that crosses the wire makes the contract obvious: future readers see "what format are these `{uuid}.json` files in?" answered next to the struct definition. ISO-8601 dates are human-readable, so payloads can be inspected in Finder / a terminal without decoding them. Pinning explicitly defends against an Apple-side default change.
+
+### Alternatives Considered
+
+- **Inline `JSONEncoder()` / `JSONDecoder()` at each call site (as in design.md)**: simpler at first sight, but creates two implicit dependencies (writer and reader must use the same defaults) with no compile-time guarantee.
+- **Wrap encode/decode behind `SharedContentManager.load` and `SharedContentWriter.write` and never expose the codec**: tests can't write payloads without invoking the production writer (which the test for "load reads a payload written to the container" wants to bypass).
+
+### Consequences
+
+**Positive:**
+- Wire format is a single source of truth, referenced from main app, extension, and tests.
+- Payloads on disk are human-readable for debugging.
+- Future change to the wire format only needs to update the factory pair.
+
+**Negative:**
+- One extra method-call hop versus inline JSONEncoder instantiation. Cost is negligible at the actual call frequency (one encode per share, one decode per shared URL handle).
+
+---
+
+## Decision 17: `NotesManager.resolveNoteContext` scopes `.shared` like `.clipboard`
+
+**Date**: 2026-05-18
+**Status**: accepted
+
+### Context
+
+`DocumentSource.supportsNotes` returns `false` for `.shared`, so callers are expected to skip notes loading for shared documents entirely. `NotesManager.resolveNoteContext(source:sessionID:)` is reachable from several entry points — if any caller forgets to gate on `supportsNotes`, the function must still return a valid identifier and display name rather than crash. Swift's exhaustive `switch` over `DocumentSource` forces us to pick a behaviour for `.shared` in this function.
+
+### Decision
+
+Treat `.shared` like `.clipboard` inside `resolveNoteContext`: derive the identifier via `DocumentIdentifierResolver.resolve(forClipboardSession:)` using the session UUID, and use the original filename as the display name. Document the rationale inline so a future reader doesn't read this as endorsement of notes on shared documents.
+
+### Rationale
+
+A session-scoped identifier is the only correct fallback. Shared documents have no stable identity (the App Group file is deleted as soon as we load it), so any persisted notes would orphan immediately. Routing through the clipboard-session resolver makes accidental writes self-cleaning when the session ends, matches the existing pattern for sources with no on-disk identity, and avoids introducing a new identifier flavour. A `preconditionFailure` would be the alternative but is wrong because `resolveNoteContext` is reachable from defensive code paths that should not crash.
+
+### Alternatives Considered
+
+- **`preconditionFailure("supportsNotes == false")`**: enforces the gate but converts a benign mis-routing into a crash. Unwarranted given the function is total in every other case.
+- **New `DocumentIdentifierResolver.resolve(forSharedSession:)` returning a session-scoped identifier with a `shared/` prefix**: more correct but requires a new method, new tests, and a migration concern if shared-document notes ever become a feature.
+- **Return an empty identifier and rely on later code to skip**: silent and likely to surface as a hard-to-debug regression in the notes pipeline.
+
+### Consequences
+
+**Positive:**
+- `resolveNoteContext` stays total; no crash path from any caller.
+- Any accidental writes route to a session-scoped namespace that vanishes with the session.
+- Future "notes on shared documents" support can replace this with a real resolver case without breaking callers.
+
+**Negative:**
+- Hides the `supportsNotes == false` contract — a caller that ignores the gate sees no immediate error.
+
+---
diff --git a/specs/share-extension/tasks.md b/specs/share-extension/tasks.md
index 0e84230..f33413a 100644
--- a/specs/share-extension/tasks.md
+++ b/specs/share-extension/tasks.md
@@ -8,7 +8,7 @@ references:
## Data Models
-- [ ] 1. Write tests for DocumentSource.shared properties <!-- id:j8a8uu0 -->
+- [x] 1. Write tests for DocumentSource.shared properties <!-- id:j8a8uu0 -->
- Test isUnsaved returns false
- Test isFile returns false
- Test imageBaseURL returns nil (Req 4.5)
@@ -21,7 +21,7 @@ references:
- Requirements: [4.3](requirements.md#4.3), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5)
- References: prism/Models/DocumentSource.swift
-- [ ] 2. Add DocumentSource.shared and DocumentSourceType.shared cases <!-- id:j8a8uu1 -->
+- [x] 2. Add DocumentSource.shared and DocumentSourceType.shared cases <!-- id:j8a8uu1 -->
- Add .shared(filename: String) case to DocumentSource enum
- Update all switch statements: isUnsaved, isFile, isBundled, isURL, displayTitle, displayTitleWithIndicator, url, supportsNotes, imageBaseURL, imageSourceType
- Add .shared case to DocumentSourceType enum
@@ -31,7 +31,7 @@ references:
- Requirements: [4.3](requirements.md#4.3), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5), [3.9](requirements.md#3.9)
- References: prism/Models/DocumentSource.swift, prism/Models/DocumentSession.swift
-- [ ] 3. Create SharedContentPayload Codable struct <!-- id:j8a8uu2 -->
+- [x] 3. Create SharedContentPayload Codable struct <!-- id:j8a8uu2 -->
- Create SharedContentPayload with fields: originalFilename (String) and createdAt (Date) and content (String)
- Place in prism/Models/SharedContentPayload.swift
- This struct is duplicated in the extension target (Decision 12)
@@ -39,7 +39,7 @@ references:
- Requirements: [5.7](requirements.md#5.7)
- References: specs/share-extension/design.md
-- [ ] 4. Write tests for SharedContentPayload encode/decode roundtrip <!-- id:j8a8uu3 -->
+- [x] 4. Write tests for SharedContentPayload encode/decode roundtrip <!-- id:j8a8uu3 -->
- Test Codable roundtrip preserves all fields
- Test JSON structure matches expected format
- Test with special characters in filename and content
@@ -48,7 +48,7 @@ references:
- Requirements: [5.7](requirements.md#5.7)
- References: prism/Models/SharedContentPayload.swift
-- [ ] 5. Create SharedContentError enum <!-- id:j8a8uu4 -->
+- [x] 5. Create SharedContentError enum <!-- id:j8a8uu4 -->
- Create SharedContentError with cases: appGroupUnavailable and writeFailure and contentNotFound
- Conform to Error and LocalizedError
- Place in prism/Models/SharedContentError.swift
@@ -58,7 +58,7 @@ references:
## SharedContentManager
-- [ ] 6. Write tests for SharedContentManager load/delete/cleanup <!-- id:j8a8uu5 -->
+- [x] 6. Write tests for SharedContentManager load/delete/cleanup <!-- id:j8a8uu5 -->
- Test write and read roundtrip via load(id:)
- Test delete(id:) removes the JSON file
- Test cleanupStaleFiles skips files < 60 seconds old
@@ -71,7 +71,7 @@ references:
- Requirements: [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [5.8](requirements.md#5.8), [4.2](requirements.md#4.2), [4.6](requirements.md#4.6), [4.8](requirements.md#4.8)
- References: prism/Services/SharedContentManager.swift
-- [ ] 7. Implement SharedContentManager <!-- id:j8a8uu6 -->
+- [x] 7. Implement SharedContentManager <!-- id:j8a8uu6 -->
- Create SharedContentManager enum with static methods: load(id:) and delete(id:) and cleanupStaleFiles()
- App Group ID: group.me.nore.ig.Prism
- Subdirectory: shared-content/
@@ -84,7 +84,7 @@ references:
- Requirements: [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [5.8](requirements.md#5.8), [4.2](requirements.md#4.2), [4.6](requirements.md#4.6), [4.8](requirements.md#4.8)
- References: specs/share-extension/design.md
-- [ ] 8. Add App Group entitlement to main app <!-- id:j8a8uu7 -->
+- [x] 8. Add App Group entitlement to main app <!-- id:j8a8uu7 -->
- Add com.apple.security.application-groups key to prism/prism.entitlements
- Value: group.me.nore.ig.Prism
- This is a configuration change - no test needed
@@ -94,7 +94,7 @@ references:
## Session & Coordinator Integration
-- [ ] 9. Write tests for DocumentSession shared content initializer <!-- id:j8a8uu8 -->
+- [x] 9. Write tests for DocumentSession shared content initializer <!-- id:j8a8uu8 -->
- Test init(sharedContent:filename:) sets source to .shared(filename:)
- Test fileObserver is nil
- Test content is set correctly
@@ -105,7 +105,7 @@ references:
- Requirements: [4.3](requirements.md#4.3)
- References: prism/Models/DocumentSession.swift
-- [ ] 10. Add DocumentSession init(sharedContent:filename:) initializer <!-- id:j8a8uu9 -->
+- [x] 10. Add DocumentSession init(sharedContent:filename:) initializer <!-- id:j8a8uu9 -->
- Add new initializer following the pattern of existing init(remoteURL:displayURL:content:)
- Set source to .shared(filename: filename)
- Set fileObserver to nil
@@ -115,7 +115,7 @@ references:
- Requirements: [4.3](requirements.md#4.3)
- References: prism/Models/DocumentSession.swift
-- [ ] 11. Write tests for DocumentFlowCoordinator open-shared URL handling <!-- id:j8a8uua -->
+- [x] 11. Write tests for DocumentFlowCoordinator open-shared URL handling <!-- id:j8a8uua -->
- Test handleOpenURL routes prism://open-shared?id={valid-uuid}
- Test handleOpenURL ignores prism://open-shared?id=invalid-string
- Test handleOpenURL ignores prism://open-shared with no id parameter
@@ -127,7 +127,7 @@ references:
- Requirements: [4.1](requirements.md#4.1), [4.7](requirements.md#4.7), [4.9](requirements.md#4.9)
- References: prism/ViewModels/DocumentFlowCoordinator.swift
-- [ ] 12. Implement open-shared URL handler in DocumentFlowCoordinator <!-- id:j8a8uub -->
+- [x] 12. Implement open-shared URL handler in DocumentFlowCoordinator <!-- id:j8a8uub -->
- Add open-shared case to handleOpenURL
- Add requestOpenSharedDocument(id:) with unsaved confirmation flow
- Add openSharedDocument(id:) that loads from SharedContentManager and creates DocumentSession and navigates and deletes shared file
@@ -139,7 +139,7 @@ references:
- Requirements: [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [4.6](requirements.md#4.6), [4.8](requirements.md#4.8), [4.9](requirements.md#4.9), [4.10](requirements.md#4.10)
- References: prism/ViewModels/DocumentFlowCoordinator.swift, prism/prismApp.swift
-- [ ] 13. Wire stale file cleanup into app launch <!-- id:j8a8uuc -->
+- [x] 13. Wire stale file cleanup into app launch <!-- id:j8a8uuc -->
- Add .task modifier in prismApp.swift that calls SharedContentManager.cleanupStaleFiles()
- Add 2-second delay before cleanup to ensure onOpenURL has processed pending URLs
- This prevents race condition where cleanup deletes a file about to be loaded
@@ -150,20 +150,20 @@ references:
## Share Extension Target
-- [ ] 14. Create SharedContentPayload in extension target <!-- id:j8a8uud -->
+- [x] 14. Create SharedContentPayload in extension target <!-- id:j8a8uud -->
- Duplicate SharedContentPayload struct in PrismShareExtension/SharedContentPayload.swift
- Must match main app version exactly (Decision 12)
- Same fields: originalFilename and createdAt and content
- Stream: 2
- Requirements: [5.7](requirements.md#5.7)
-- [ ] 15. Create SharedContentError in extension target <!-- id:j8a8uue -->
+- [x] 15. Create SharedContentError in extension target <!-- id:j8a8uue -->
- Duplicate SharedContentError enum in PrismShareExtension/SharedContentError.swift
- Cases: appGroupUnavailable and writeFailure and contentNotFound
- Stream: 2
- Requirements: [8.2](requirements.md#8.2)
-- [ ] 16. Implement SharedContentWriter <!-- id:j8a8uuf -->
+- [x] 16. Implement SharedContentWriter <!-- id:j8a8uuf -->
- Create SharedContentWriter enum with static write(content:filename:) -> UUID method
- App Group ID: group.me.nore.ig.Prism and subdirectory: shared-content/
- Generate UUID and encode SharedContentPayload to JSON and write atomically
@@ -174,7 +174,7 @@ references:
- Requirements: [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [3.8](requirements.md#3.8), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.7](requirements.md#5.7)
- References: specs/share-extension/design.md
-- [ ] 17. Implement ContentExtractor with type priority <!-- id:j8a8uug -->
+- [x] 17. Implement ContentExtractor with type priority <!-- id:j8a8uug -->
- Create ContentExtractor enum with static extract(from: NSExtensionItem) async -> ExtractedContent
- Priority order: markdown UTType > file URL > web URL (most-specific first)
- extractMarkdownFile: load net.daringfireball.markdown and validate UTF-8 and 10MB limit
@@ -185,7 +185,7 @@ references:
- Requirements: [2.1](requirements.md#2.1), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [2.6](requirements.md#2.6), [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7), [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.4](requirements.md#6.4), [8.1](requirements.md#8.1), [8.4](requirements.md#8.4), [8.5](requirements.md#8.5), [8.6](requirements.md#8.6)
- References: specs/share-extension/design.md
-- [ ] 18. Implement ShareExtensionViewModel <!-- id:j8a8uuh -->
+- [x] 18. Implement ShareExtensionViewModel <!-- id:j8a8uuh -->
- Create ShareExtensionViewModel with @Observable and @MainActor
- State enum: processing and error(String)
- processSharedContent() async: validate single item and extract via ContentExtractor and route to openMainAppForURL or writeAndOpen
@@ -200,7 +200,7 @@ references:
- Requirements: [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.7](requirements.md#2.7), [2.8](requirements.md#2.8), [2.9](requirements.md#2.9), [2.10](requirements.md#2.10), [2.11](requirements.md#2.11), [3.4](requirements.md#3.4), [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [7.3](requirements.md#7.3), [7.4](requirements.md#7.4), [7.5](requirements.md#7.5), [8.1](requirements.md#8.1), [8.2](requirements.md#8.2), [8.3](requirements.md#8.3), [8.7](requirements.md#8.7), [8.8](requirements.md#8.8), [10.1](requirements.md#10.1), [10.2](requirements.md#10.2)
- References: specs/share-extension/design.md
-- [ ] 19. Implement ShareExtensionView <!-- id:j8a8uui -->
+- [x] 19. Implement ShareExtensionView <!-- id:j8a8uui -->
- Create ShareExtensionView with SwiftUI
- Processing state: ProgressView with Opening in Prism...
- Error state: warning icon and error message text and Done button
@@ -213,7 +213,7 @@ references:
- Requirements: [7.1](requirements.md#7.1), [7.3](requirements.md#7.3), [7.5](requirements.md#7.5), [7.6](requirements.md#7.6), [7.7](requirements.md#7.7)
- References: specs/share-extension/design.md
-- [ ] 20. Implement ShareViewController <!-- id:j8a8uuj -->
+- [x] 20. Implement ShareViewController <!-- id:j8a8uuj -->
- Create ShareViewController subclass of UIViewController (iOS) / NSViewController (macOS)
- Use #if os(iOS) / #elseif os(macOS) for platform-specific base class
- In viewDidLoad: create ShareExtensionViewModel with extensionContext and wrap ShareExtensionView in UIHostingController/NSHostingController and add as child
@@ -224,7 +224,7 @@ references:
- Requirements: [1.1](requirements.md#1.1), [1.7](requirements.md#1.7)
- References: specs/share-extension/design.md
-- [ ] 21. Configure extension Info.plist with activation rules <!-- id:j8a8uuk -->
+- [x] 21. Configure extension Info.plist with activation rules <!-- id:j8a8uuk -->
- Set NSExtensionPointIdentifier to com.apple.share-services
- Set NSExtensionPrincipalClass to $(PRODUCT_MODULE_NAME).ShareViewController
- Set dictionary-based NSExtensionActivationRule (Decision 13)
@@ -235,7 +235,7 @@ references:
- Requirements: [1.6](requirements.md#1.6), [9.1](requirements.md#9.1), [9.2](requirements.md#9.2), [9.5](requirements.md#9.5), [9.6](requirements.md#9.6)
- References: specs/share-extension/design.md
-- [ ] 22. Create extension entitlements file <!-- id:j8a8uul -->
+- [x] 22. Create extension entitlements file <!-- id:j8a8uul -->
- Create PrismShareExtension/PrismShareExtension.entitlements
- Add com.apple.security.application-groups: group.me.nore.ig.Prism
- Add com.apple.security.app-sandbox: true
@@ -244,7 +244,7 @@ references:
## Build Verification
-- [ ] 23. Verify both targets build for iOS and macOS <!-- id:j8a8uum -->
+- [x] 23. Verify both targets build for iOS and macOS <!-- id:j8a8uum -->
- Run make build-ios to verify iOS build with zero warnings
- Run make build-macos to verify macOS build with zero warnings
- Run make test-quick to verify all unit tests pass
diff --git a/specs/share-extension/prerequisites.md b/specs/share-extension/prerequisites.md
index c38cd7f..631244b 100644
--- a/specs/share-extension/prerequisites.md
+++ b/specs/share-extension/prerequisites.md
@@ -2,7 +2,7 @@
These tasks must be completed by the user before implementation begins.
-- [ ] **Configure App Group in Apple Developer portal**: Create an App Group with identifier `group.me.nore.ig.Prism` in the Apple Developer portal under Certificates, Identifiers & Profiles → Identifiers → App Groups. Associate it with the main app's App ID.
-- [ ] **Create PrismShareExtension target in Xcode**: Add a new Share Extension target to the project via File → New → Target → Share Extension. Name it `PrismShareExtension`. Set the bundle identifier to `{main-bundle-id}.share-extension`. Sign with the same team as the main app. Remove the auto-generated storyboard and view controller files — they will be replaced by custom implementations in the coding tasks.
-- [ ] **Add App Group capability to both targets**: In Xcode, enable the App Groups capability for both the main `prism` target and the `PrismShareExtension` target. Select `group.me.nore.ig.Prism` for both.
-- [ ] **Add extension icon assets for macOS**: Add the Prism app icon to `PrismShareExtension/Assets.xcassets` for macOS. The extension needs its own icon asset catalog entry (Req 10.3).
+- [x] **Configure App Group in Apple Developer portal**: Create an App Group with identifier `group.me.nore.ig.prism` in the Apple Developer portal under Certificates, Identifiers & Profiles → Identifiers → App Groups. Associate it with the main app's App ID. (Created via Xcode's automatic sync.)
+- [x] **Create PrismShareExtension target in Xcode**: Add a new Share Extension target to the project via File → New → Target → Share Extension. Name it `PrismShareExtension`. Set the bundle identifier to `{main-bundle-id}.share-extension` (resolved to `me.nore.ig.prism.PrismShareExtension`). Sign with the same team as the main app. Remove the auto-generated storyboard and view controller files — they will be replaced by custom implementations in the coding tasks.
+- [x] **Add App Group capability to both targets**: In Xcode, enable the App Groups capability for both the main `prism` target and the `PrismShareExtension` target. Select `group.me.nore.ig.prism` for both.
+- [x] **Add extension icon assets for macOS**: `PrismShareExtension/Assets.xcassets/AppIcon.appiconset` is a relative symlink to `prism/Assets.xcassets/AppIcon.appiconset`, so the extension shares the main app's icon source with no duplication. Updates to the main app icon flow through automatically (Req 10.3).
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.md
index b358228..43e252b 100644
--- a/specs/OVERVIEW.md
+++ b/specs/OVERVIEW.md
@@ -57,7 +57,7 @@
| [Footnotes](#footnotes) | 2026-04-01 | Done | Markdown footnote rendering with inline pill badges and popover content display |
| [GFM Tables](#gfm-tables) | 2026-04-01 | Done | Column alignment support and zebra striping for GFM tables |
| [Readable Tables](#readable-tables) | 2026-04-02 | Done | Third table display mode with 300pt max column width and three-state toggle |
-| [Share Extension](#share-extension) | 2026-04-04 | Planned | Share Extension for sharing URLs and markdown files from other apps into Prism |
+| [Share Extension](#share-extension) | 2026-04-04 | Done | Share Extension for sharing URLs and markdown files from other apps into Prism |
| [In-App Purchase](#in-app-purchase) | 2026-04-04 | Done | One-time purchase to unlock unlimited annotation exports with 20-export free tier |
| [Table Row Notes](#table-row-notes) | 2026-04-10 | Done | Row-level note anchoring for markdown tables with indicator column and export/import support |
| [Consolidate Note Popovers](#consolidate-note-popovers) | 2026-04-13 | Done | Consolidate duplicate note popover functions into parameterized variants |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8dd085a..6998943 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Pre-push review fixes for T-431 share-extension (T-431): `DocumentFlowCoordinator.openSharedDocument` now surfaces `SharedContentError.contentNotFound.errorDescription` for missing-payload errors instead of an ad-hoc inline string, so the three `error.sharedContent.*` localised keys are actually wired in (the error type was previously dead code in the main target). `SharedContentManager.containerURLOverride` and its override branch in `sharedContainerURL()` are now `#if DEBUG`-gated so the test seam is stripped from release builds — matches the existing pattern used by `DocumentFlowCoordinator.setRemoteCoordinatorForTests`. `SharedContentManager.cleanupStaleFiles()` is now `nonisolated` and the `.task` modifier in `prismApp` dispatches it onto a background `Task.detached` so the filesystem walk runs off the main actor — on macOS the `.task` is re-issued per `MainContentView` lifetime (per window), so keeping the sweep off-main matters. `SharedContentPayload` is marked `nonisolated` and `Sendable` so its `Decodable` conformance is reachable from the nonisolated `load(id:)` call. Decisions 15, 16, and 17 added to the decision log: DEBUG-only test seam, the wire-format pinning via `makeEncoder()` / `makeDecoder()`, and the `NotesManager.resolveNoteContext` defensive fallback for `.shared` despite `supportsNotes == false`. CLAUDE.md gains a one-line entry pointing at `share-extension/` under the spec listing.
+- T-431 share-extension spec is now fully implemented end-to-end across the main app and `PrismShareExtension` target. A user can share a `.md` file from Files / Mail / Safari (web URL or `.md` attachment) on iOS, iPadOS, or macOS and have Prism receive it: URLs hit the existing `prism://open?url=` route, files travel through the App Group container at `group.me.nore.ig.prism/shared-content/{uuid}.json` and arrive via `prism://open-shared?id={uuid}`. The extension binary builds for both iOS and macOS. The main app's `MARKETING_VERSION` (0.10.0) is now propagated to the extension target so the embedded-binary validator stops warning about a version mismatch. Phase 5 verification: `make build-ios` clean, `xcodebuild build` for macOS clean (signing skipped via `CODE_SIGNING_ALLOWED=NO` until the new bundle id is registered with the developer account), `make lint` clean, test target compiles cleanly (`xcodebuild build-for-testing` succeeds). Test runtime on `test-quick` hangs at simulator/runner boot in the local environment — known issue independent of this work.
+- `PrismShareExtension` target now has its end-to-end implementation (T-431, Phase 4 of share-extension spec). New files in `PrismShareExtension/`: `SharedContentPayload.swift` and `SharedContentError.swift` duplicate the main-app models per Decision 12 — the duplication is intentional, the `makeEncoder()` / `makeDecoder()` factories pin the wire format. `SharedContentWriter` writes the JSON payload atomically to `group.me.nore.ig.prism/shared-content/{uuid}.json` and throws `SharedContentError.appGroupUnavailable` / `.writeFailure` on failure. `ContentExtractor` extracts a single attachment in type-priority order: `net.daringfireball.markdown` → `public.file-url` (only `.md`/`.markdown` accepted) → `public.url` (`http`/`https` only). The priority order is load-bearing — `public.file-url` conforms to `public.url`, so checking the URL type first would misclassify shared `.md` files. The extractor enforces the 10 MB limit (Req 8.4), UTF-8 validation (Req 8.5), and rejects non-http(s) URL schemes (Req 2.6). `ShareExtensionViewModel` (`@Observable @MainActor`) drives the flow: validate single-item share, extract, then either build `prism://open?url=` for URLs or write to the App Group container and build `prism://open-shared?id=` for files. It opens the main app via `NSExtensionContext.open(_:completionHandler:)` on both platforms (Req 10.1) and dismisses on success; on failure it surfaces a localised error with a 3-second auto-dismiss timer that is skipped while VoiceOver is active (Reqs 7.4–7.5, 8.8). `ShareExtensionView` is the minimal SwiftUI surface — a progress indicator while processing, a warning card with a "Done" button on error. `ShareViewController` is the extension's principal class: `UIViewController` on iOS, `NSViewController` on macOS, each hosting `ShareExtensionView` via the appropriate `HostingController`. `Info.plist` switches from the Xcode-generated `TRUEPREDICATE` activation rule to the dictionary-based form (`NSExtensionActivationSupportsWebURLWithMaxCount` / `NSExtensionActivationSupportsFileWithMaxCount`, both `1`) per Decision 13, and points `NSExtensionPrincipalClass` at the new view controller. The auto-generated `MainInterface.storyboard` is removed.
+- `DocumentFlowCoordinator` now handles `prism://open-shared?id={uuid}` URLs from PrismShareExtension (T-431, Phase 3 of share-extension spec). A new `DocumentSession(sharedContent:filename:)` initializer mirrors the remote-URL pattern: no `FileChangeObserver`, no persisted state, no recent-files entry. `handleOpenURL` parses the UUID via `UUID(uuidString:)` (the single line of defence against path traversal — anything malformed is rejected before `SharedContentManager` touches the filesystem) and routes through `requestOpenSharedDocument(id:)`. If the current session is unsaved the new `PendingAction.openShared(id:)` is enqueued and the existing unsaved-confirmation dialog runs; on discard, `openSharedDocument(id:)` loads the payload via `SharedContentManager.load(id:)`, cancels any in-flight remote download, activates the new `.shared` session, deletes the payload from the container (Req 4.6), and on macOS calls `NSApplication.shared.activate()` so the window comes forward when the share sheet activated us from another app (Req 4.10). Missing payloads surface `loadError` (Req 4.8). `prismApp` runs `SharedContentManager.cleanupStaleFiles()` once on launch with a 2-second delay so any pending `onOpenURL` fires first (Req 5.6). New tests in `prismTests/DocumentSessionSharedInitTests.swift` and `prismTests/DocumentFlowCoordinatorSharedURLTests.swift` cover the session initializer, valid/invalid/missing UUID routing, the missing-payload error path, the unsaved-confirmation flow including discard-and-proceed, and that the existing `prism://open?url=` route is unaffected.
+- `SharedContentManager` (`prism/Services/SharedContentManager.swift`) for reading and cleaning the App Group container that PrismShareExtension writes shared markdown payloads into (T-431, Phase 2 of share-extension spec). Static API: `load(id:) -> SharedContentPayload?`, `delete(id:)`, and `cleanupStaleFiles()`. Path traversal is prevented by construction — the caller has already passed `UUID(uuidString:)` before any filesystem touch, so the path components are restricted to hex digits and dashes. `cleanupStaleFiles()` walks the `shared-content/` subdirectory of `group.me.nore.ig.prism`, skips files created in the last 60 seconds (race guard against an actively writing extension), and removes anything older than 24 hours. Files without a readable `.creationDateKey` are left alone. A `containerURLOverride` test hook lets unit tests substitute a temp directory for the real App Group container. `SharedContentPayload` grows static `makeEncoder()` / `makeDecoder()` factories that both the manager (read side) and the upcoming `SharedContentWriter` (write side) MUST use, so the date format stays in sync — ISO-8601 to keep payloads human-readable when inspecting the container during debugging. New tests in `prismTests/SharedContentManagerTests.swift` cover roundtrip load, missing file, corrupt JSON, delete, delete-of-missing, cleanup-removes-stale, cleanup-skips-recent (race guard), and cleanup-keeps-mid-aged (10 minutes old). Decision 14 records that the App Group identifier is lowercase `group.me.nore.ig.prism` everywhere — the spec's earlier capital-`P` form is documentation drift relative to what Xcode registered in the developer portal.
+- Foundation models for the Share Extension feature (T-431, Phase 1 of share-extension spec). `DocumentSource` gains a `.shared(filename: String)` case for content delivered by `PrismShareExtension`; the matching `DocumentSourceType.shared` is added so `imageBaseURL`, `imageSourceType`, `supportsNotes`, `isUnsaved`, `isFile`, `isURL`, `isBundled`, `displayTitle`, `displayTitleWithIndicator`, and `url` all return the values specified in the design (`supportsNotes == false`, `imageBaseURL == nil` per Req 4.5, `displayTitleWithIndicator` returns the filename without a bullet). `DocumentSession.resolvedDocumentIdentifier()` returns `nil` for shared sessions so scroll-position persistence treats them as ephemeral. Path resolvers (`ImagePathResolver`, `LinkPathResolver`) treat `.shared` like `.clipboard` — no base URL, no relative resolution. `NotesManager.resolveNoteContext` scopes any accidental notes to the session via `resolve(forClipboardSession:)` since shared documents don't have a stable identity. New `SharedContentPayload` (`prism/Models/SharedContentPayload.swift`) is the on-wire JSON contract for the App Group handoff (`originalFilename`, `createdAt`, `content`) — single-file payload per Decision 12. New `SharedContentError` (`prism/Models/SharedContentError.swift`) covers `appGroupUnavailable`, `writeFailure`, and `contentNotFound` with localised messages reused by the extension UI. Unit tests added: `DocumentSourceSharedTests` (10 cases) and `SharedContentPayloadTests` (4 cases including roundtrip with special characters).
- Render markdown tables that appear inside list items as tables instead of raw markdown text (T-1034). `MarkdownBlockParser.convertListItem` now emits a `.block(.table(...))` child for swift-markdown `Table` nodes nested under a list item, and `ListBlockView` renders them via a new private `NestedTableBlockView` wrapper that delegates to the existing `AccessibleTableView`. The wrapper hides the row-indicator column (per-row notes on nested tables deferred per ticket) and initialises its display mode via `TableDisplayMode.initialMode(headers:rows:)` so wider tables fall back to readable mode automatically. Test coverage extends `ListItemNestedBlocksTests` with four cases (parser emits the table child, item content no longer contains pipe text, alignment markers round-trip, list-block id structural fingerprint pinned against silent ID drift). Knowingly orphans any existing note attached to a list item that contains a table — the item's `contentForHashing` necessarily changes — accepted per `specs/tables-in-list-items/decision_log.md`.
- Research report on adding HTML viewing with annotations to Prism, covering WKWebView, native parse, AttributedString, HTML→Markdown, and hybrid rendering options plus a Hypothesis-style note-anchoring cascade and a layered security model (sanitisation, CSP, scheme handler). Stored under `docs/agent-notes/` and not bundled in the app.
- `prism-notes-js` spec for a self-contained single-file JavaScript library that adds basic note-taking and copy-as-markdown to any HTML page (personal-use companion artifact; not part of the Prism Swift app). Smolspec, decision log, and 8 implementation tasks live under `specs/prism-notes-js/`.
diff --git a/CLAUDE.md b/CLAUDE.md
index 4c75d8f..a2064bd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -249,6 +249,7 @@ Feature specifications live in `specs/` with subfolders for each feature:
- `macos-save-to-file/`: macOS File > Export... menu command via NSSavePanel
- `table-row-notes/`: Row-level note support for markdown tables with indicator column and export/import
- `inapp-purchase/`: One-time non-consumable unlock for unlimited annotation exports plus 3 consumable tip products; 20-export free tier
+- `share-extension/`: System share-sheet target (`PrismShareExtension/`, iOS + macOS) for receiving URLs and `.md` files from other apps. Files travel through the App Group `group.me.nore.ig.prism` (lowercase per Decision 14) as `{uuid}.json` payloads; URLs reuse the existing `prism://open?url=` scheme. The extension target reuses the main app's icon via a relative symlink at `PrismShareExtension/Assets.xcassets/AppIcon.appiconset`.
Each feature folder contains `requirements.md`, `design.md`, `tasks.md`, and `decision_log.md`.
The new bundle id me.nore.ig.prism.PrismShareExtension needs to be registered with the developer account before xcodebuild can sign it on macOS. Open the target in Xcode → Signing & Capabilities once, or run xcodebuild -allowProvisioningUpdates with a signed-in account. Until then, make build-macos and make test-quick both fail at the signing step — verification has been done with CODE_SIGNING_ALLOWED=NO.
design.md lists six on-device validations Apple's guidance has historically been inconsistent about (auto-dismiss on iOS, foreground activation on macOS, etc). Run the Safari/Files/Mail combinations from the design's Manual Test Matrix before shipping a release that includes T-431.
Several new keys were added: shareExt.*, alert.openShared.notFound, error.sharedContent.*. The build pipeline auto-extracts from String(localized:defaultValue:) and the Text literals via STRING_CATALOG_GENERATE_SYMBOLS; verify the merged catalog at next full Xcode build and confirm the en-AU override file at specs/localisation/en-AU-overrides.json doesn't need new entries.