Second pass on PR #411 (head a8c49440) reviewed against origin/main (2b10c6dc). The previous review's four follow-ups (guard ordering, test determinism, reader implementation, stale docs) are re-verified below. Read-only review: nothing was edited; static checks and the targeted test run used a git archive export under the job directory.
WebViewPool.selectIOSWindow, a global connectedScenes scan documented as "any equal-state scene is interchangeable" — right for offscreen WebViews, wrong for a user-facing sheet. With two iPad scenes it could present in the other document's window.ScenePresentationAnchor (weak hostView, window computed on read) mounted by .capturingScenePresentation(_:) at the DocumentReaderView root, threaded DocumentLayoutCoordinator → ExportNotesFlow → InlineNotesShareHelper.share. No global fallback: a missing window fails the share as .presentationUnavailable with its own "Share Unavailable" alert.export-inline-notes.md, webview-pool.md updated; CLAUDE.md Source Structure still omits the new file).Tools/check-webkit-test-isolation.py OK; SwiftLint clean on all six changed Swift files; catalog entries for both new strings present byte-identically with en/en-GB/en-US units.xcodebuild test (iOS Simulator, iPhone 17 Pro, -only-testing the three affected suites, fresh derived data in the export) passed: 36 tests, 0 failures, confirmed by Tools/check-test-results.sh on the result bundle. Disclosure: a clean build took the run to 13m46s, past the 600 s foreground budget, and the harness moved it to the background on its own — it was not launched that way. The JUnit file xcbeautify wrote lists each case twice (72 entries for 36 tests); the bundle guard's count is the one to trust. No coverage was collected.Needs fixes
The production change is correct and the four follow-ups from the previous review are all addressed in a8c49440: the presenter is resolved before the export is rendered or written (both platforms), the anchor holds the reader's UIView weakly and resolves window on read, no test presents a live UIActivityViewController, and both living agent-notes are current. One defect blocks: the three "writing nothing" assertions in the new InlineNotesShareHelperScenePresentationTests are vacuous. share names the temp file from notesManager.documentNotes?.displayName ?? "document.md", and NotesManager.makeForTesting(store:) never loads notes, so any file it wrote would be document-<uuid>.md — which can never match the share-scene-affinity-… prefix the test filters on. The guard-ordering contract the commit message, bugfix report, and PR overview all claim is pinned is therefore not pinned; moving the guard back below the write leaves every test green. It is a small, test-only fix (snapshot the temp directory before and after the call, or load a DocumentNotes whose displayName matches). Everything else is minor or nit.
866cb487 Fix T-1831: iPad Share with Notes can present in the wrong document scene a8c49440 Fix T-1831 review: resolve the share window at share time, own alert for no window On an iPad you can have two Prism documents open side by side, each in its own window. When you tap Share with Notes, the app has to pick a window to show the share sheet in. Before this change it asked the system for "any window that is currently active" — and with two active windows, the system could hand back the other document's window, so the share sheet popped up next to the wrong document.
Now each document screen carries a tiny invisible marker view (the ScenePresentationAnchor) that lives inside that screen's own window. When you share, the app asks that marker "which window are you in right now?" and presents there. It never guesses from the global list any more.
The sheet appears where you tapped. If, very rarely, the marker is not inside any window (the screen is being torn down), the app shows a short "Share Unavailable — please try again" message instead of the old, misleading "Export Failed" message — because the export file was fine; only the window was missing.
prism/Views/ScenePresentationAnchor.swift (new): a @MainActor final class with weak var hostView: UIView? and var window: UIWindow? { hostView?.window }; a private UIViewRepresentable reader whose makeUIView/updateUIView both assign anchor.hostView; a .capturingScenePresentation(_:) View extension. All iOS-only; on macOS the class is an empty token so both platforms share one share signature.DocumentReaderView: attaches the modifier at the screen root (outside the compact/regular branch), #if os(iOS).DocumentLayoutCoordinator: owns let presentationAnchor, hands it to its lazily-built ExportNotesFlow. ShareAction gains an anchor parameter and returns a new ShareOutcome (.presented / .exportFileUnavailable / .presentationUnavailable); performShare maps the last to a new showPresentationError flag, cleared by reset().InlineNotesShareHelper.share: resolves the presenter (iOS: presentingViewController(for:), the topmost-presented walk over the anchor window's root; macOS: key window) before exportWithInlineNotes and the temp-file write; drops the WebViewPool.selectIOSWindow call entirely. New "Share Unavailable" alert with catalog strings.Follows the T-1779 PaywallPresenter idea of per-scene ownership, but for an imperative UIKit presentation that SwiftUI's declarative .sheet scoping cannot cover. The carrier is the coordinator (already per-screen @State) rather than an environment value, because the consumer is a non-View object the coordinator already wires (flow.onBanner), and it keeps the two-scene identity assertions testable without a view host.
Storing the view rather than its window is the fix for the previous review's edge case: view.window is only set once UIKit attaches the view, which is after the SwiftUI pass that creates the representable, so a window cached from a deferred read was nil for a run-loop turn after every mount and document switch. Reading through the view at share time has no cache to keep in step.
Correctness hinges on three properties, all present: (1) the weak hostView cannot retain a window or scene, and UIKit clears a removed view's window itself, so no teardown hook is required; (2) ScenePresentationAnchor is not @Observable, DocumentLayoutCoordinator.presentationAnchor is a let (the macro adds no accessor), and ExportNotesFlow.presentationAnchor is @ObservationIgnored, so the per-update updateUIView weak store cannot feed back into SwiftUI invalidation; (3) the presenter is resolved before the O(document) render on both platforms — the macOS branch holds the NSWindow across a synchronous MainActor render, which is safe.
The ShareAction stub seam is what lets ExportNotesFlowTests assert scene identity (receivedAnchors.first === coordinatorA.presentationAnchor) without UIKit, and presentingViewController(for:) is what lets the iOS suite assert own-window resolution without presenting a live UIActivityViewController from a scene-less window — the previous review's determinism concern.
Adds a second per-scene carrier next to PaywallPresenter, but for a different presentation mode; the bugfix report's prevention note is the right rule (any new imperative UIKit presentation takes an anchor, never selectIOSWindow). WebViewPool.selectIOSWindow keeps its two legitimate callers (MermaidRenderer, SystemColorSchemeObserver). The #if os(iOS) gate on the DocumentReaderView modifier means the wiring compiles out of make test-quick (macOS) — deleting that one line leaves every new test green while every iOS share fails as .presentationUnavailable. The repo pins exactly this shape elsewhere (PaywallPresenterTests.bothScenesAttachThePaywallHost counts .paywallPresentation( in source; WindowAttachmentWiringTests via ProductionSourceScan).
exportFilesWritten() filters the temp dir on sanitizedBaseName(from: documentName), but share names the file from notesManager.documentNotes?.displayName ?? "document.md"; with makeForTesting(store:) that is nil, so the prefix is document-. The assertion can never observe a write.makeUIView overwrites hostView with a not-yet-attached view while the old attached one still exists. A user tap cannot land inside SwiftUI's commit transaction, so this is unreachable in practice; the "no such gap" doc sentence is slightly stronger than the code.readerCapturesHostingWindow makes two extra windows visible on the shared host scene and its defer never detaches windowScene; suites run concurrently and SystemColorSchemeObserverTests is explicit that window state is process-global. Assigning rootViewController already inserts the hosting view, so isHidden = false is unnecessary.isBeingDismissed; unchanged behaviour, out of scope.prism/Views/ScenePresentationAnchor.swift
Why it matters. This is the whole fix and the answer to the previous review's edge case. A weak UIView plus a computed `window` means nil only when the view is genuinely detached, never during the post-mount run-loop turn the deferred-window version lost.
What to look at. prism/Views/ScenePresentationAnchor.swift:38-68 (class), 70-102 (reader), 104-113 (modifier)
prism/Services/InlineNotesShareHelper.swift
Why it matters. Guard ordering (previous follow-up) and the distinct failure cases both live here. A missing window now costs no O(document) render and leaks no temp file, and the global WebViewPool.selectIOSWindow scan is gone from this path.
What to look at. prism/Services/InlineNotesShareHelper.swift:20-31 (ShareOutcome), 84-97 (guards), 140-152 (presentingViewController(for:))
prism/Views/DocumentLayoutCoordinator.swift
Why it matters. Coordinator ownership is what makes scene identity assertable without a view host, and the new flag is cleared by reset() like every other alert flag. Note the flow defaults to a throwaway anchor that the coordinator immediately overwrites — a runtime convention, not a compile-time guarantee.
What to look at. prism/Views/DocumentLayoutCoordinator.swift:324-341, 775-836, 954-984
prismTests/InlineNotesShareHelperTests.swift
Why it matters. The suite correctly asserts own-window resolution, lazy attachment, detachment, and drives the real modifier through UIHostingController. But exportFilesWritten() filters on a prefix derived from the session URL while share names the file from notesManager.documentNotes?.displayName (nil here → 'document-'), so #expect(try exportFilesWritten().isEmpty) passes regardless of guard ordering.
What to look at. prismTests/InlineNotesShareHelperTests.swift:225-232 (documentName), 251-259 (exportFilesWritten), 270 / 289 / 310 (assertions)
prism/Views/DocumentReaderView.swift
Why it matters. One line, outside the compact/regular branch, so both layouts are covered. It is #if os(iOS)-gated and therefore invisible to make test-quick; nothing pins it, and the repo has precedent for source-scan wiring tests on exactly this failure shape (T-1943, PaywallPresenterTests).
What to look at. prism/Views/DocumentReaderView.swift:419-425
A missing anchor window fails the share (.presentationUnavailable) rather than falling back to WebViewPool.selectIOSWindow. Presenting from the wrong scene is exactly the bug, so a guess is worse than an honest alert. Stated in the share doc comment and the bugfix report.
Rejected alternatives recorded in the report: a deferred-read window cache (the timing gap the first review found) and a didMoveToWindow override (exact but duplicates state UIKit already holds). The macOS HostingWindowFinder was not reused because it is macOS-only and publishes the window itself.
The consumer is ExportNotesFlow, a non-View object the coordinator already configures, and the two-scene identity tests need no view host. PaywallPresenter is environment-injected because its consumer is a View. Recorded in the anchor's and coordinator's doc comments.
The old Bool collapsed a persistent file-write failure and a transient no-window failure into one alert whose copy was false for the second. Two catalog strings (en/en-GB/en-US) added. From PR overview Issue 2 and commit a8c49440.
Avoids an O(document) render and a leaked temp file for a share that cannot be presented, and lets the failure be reported as what it is. Commit a8c49440 and the share doc comment.
presentingViewController(for:) was split out so the scene-affinity contract is asserted by resolving a presenter, not by presenting a UIActivityViewController from a scene-less window in the shared test host. From commit a8c49440.
Keeps zero-argument ExportNotesFlow() valid for previews and tests that never reach share. The cost is a dead second anchor per screen and a wiring that is a runtime convention rather than an initialiser requirement.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | prismTests/InlineNotesShareHelperTests.swift — exportFilesWritten() | The 'nothing was written' assertions (shareFailsWithoutAWindow, anchorCapturedBeforeWindowAttachmentResolvesLazily, detachedViewFailsTheShare) filter the temp directory on sanitizedBaseName(from: documentName), i.e. a 'share-scene-affinity-…' prefix. share() names the file from notesManager.documentNotes?.displayName ?? "document.md"; NotesManager.makeForTesting(store:) never loads notes, so documentNotes is nil and any write would be 'document-<uuid>.md'. The assertion can never see a write: moving the presenter guard back below the write leaves the suite green. The commit message, bugfix report, and PR overview all claim this contract is pinned. | Must fix before push (test-only). Either snapshot contentsOfDirectory(temporaryDirectory) before the share call and assert it is unchanged after (provider-agnostic), or load a DocumentNotes whose displayName == documentName into the manager and add a no-IO control asserting temporaryExportURL(for: documentName).lastPathComponent.hasPrefix(base). Not applied: this review is read-only. |
| minor | prismTests/InlineNotesShareHelperTests.swift — readerCapturesHostingWindow | Creates two UIWindow(windowScene:) on the shared test host's scene and sets isHidden = false on both; the defer hides them and drops rootViewController but never sets windowScene = nil, so they stay in scene.windows. Suites run concurrently and SystemColorSchemeObserverTests documents that window state is process-global (it only mutates the existing host window and restores key-ness). Assigning rootViewController already inserts the hosting view, so hosting.view.window resolves without making the window visible. | Drop both isHidden = false lines (keep layoutIfNeeded()) and add window.windowScene = nil to the defer. Not applied (read-only). |
| minor | prism/Views/DocumentLayoutCoordinator.swift:329, 835 — anchor wiring | ExportNotesFlow.presentationAnchor defaults to a new ScenePresentationAnchor() that the coordinator's lazy initialiser immediately overwrites. Two anchors per screen, one dead, and the wiring is a runtime convention — forgetting the assignment compiles and makes every iOS share fail as .presentationUnavailable. | init(presentationAnchor: ScenePresentationAnchor = ScenePresentationAnchor()) on ExportNotesFlow, stored as let, constructed as ExportNotesFlow(presentationAnchor: presentationAnchor). Zero-arg path stays for previews/tests. Not applied (read-only). |
| minor | prism/Views/DocumentReaderView.swift:424 — unpinned wiring | .capturingScenePresentation(coordinator.presentationAnchor) is #if os(iOS)-gated, so it compiles out of make test-quick; deleting it leaves every new test green while the anchor stays view-less and every iOS share fails. This is the T-1943 failure shape CLAUDE.md warns about, and the repo pins it elsewhere (PaywallPresenterTests.bothScenesAttachThePaywallHost counts '.paywallPresentation(' in source; WindowAttachmentWiringTests via ProductionSourceScan). | Add a source-scan test asserting DocumentReaderView.swift contains '.capturingScenePresentation(coordinator.presentationAnchor)' exactly once. Not applied (read-only). |
| nit | prism/Views/DocumentLayoutCoordinator.swift:780-786 — ShareAction | ShareAction is now five unlabelled positional parameters; a mis-ordered stub in a future test compiles silently. | Optional: group notesManager/session/settings/anchor into a ShareRequest struct. Pre-existing shape (was four); not required for this fix. |
| nit | CLAUDE.md — Source Structure | The Views/ listing names PaywallPresenter.swift and smaller files but not the new ScenePresentationAnchor.swift (108 lines). Living agent-notes are current. | Add a Views/ line: 'ScenePresentationAnchor.swift # Per-scene window anchor for imperative UIKit presentation (Share with Notes)'. Not applied (read-only). |
| nit | prism/Views/ScenePresentationAnchor.swift:78-84, DocumentLayoutCoordinator.swift:339-340 — doc wording | 'Handing over the view synchronously has no such gap' is slightly stronger than the code: on a representable rebuild makeUIView overwrites hostView with a not-yet-attached view while the old attached one still exists (unreachable by a user tap, which cannot land inside SwiftUI's commit). The coordinator comment says the flow 'reads it (once, through the reference above) at share time' — it copies the reference at flow creation and reads its own stored reference at share time. | Soften the first sentence ('no gap a user action can land in'); reword the second. Not applied (read-only). |
| nit | prism/Views/ScenePresentationAnchor.swift — placement | The macOS analogue HostingWindowFinder lives in prism/Views/Helpers/; the new iOS reader sits in prism/Views/. Co-locating makes the pair discoverable. | Optional move to Views/Helpers/. Not applied (read-only). |
| nit | prism/Services/InlineNotesShareHelper.swift:142-147 — presentingViewController(for:) (pre-existing) | The topmost-presented walk does not skip a controller with isBeingDismissed, so a sheet mid-dismissal could swallow the presentation. Identical to the walk that was inline before this branch. | Out of scope; one-line 'where !presented.isBeingDismissed' if wanted later. |
Source: local run at 2026-09-06T02:40:22+10:00 · snapshot a8c49440618510b1f2cd59428c48e1252edb2f8e
Baseline: none
Execution: passed · JUnit: 1 file · Coverage: none · Baseline: absent
Coverage scope: as the project configures it
Totals: 36 passed · 0 failed · 0 skipped · 0 errored · 0 flaky
Derived by declaration name, from the diff (no baseline run).
Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base 2b10c6dc4944d8e50c99e6b60e962591a230b39f.
prism/Resources/mermaid.min.js — blob over 1 MBClick to expand.
diff --git a/prism/Views/ScenePresentationAnchor.swift b/prism/Views/ScenePresentationAnchor.swiftnew file mode 100644index 00000000..157bc18e--- /dev/null+++ b/prism/Views/ScenePresentationAnchor.swift@@ -0,0 +1,108 @@+//+// ScenePresentationAnchor.swift+// prism+//+// Scene-local presentation anchor for Share-with-Notes, plus the reader+// that keeps it pointed at the document screen's own view hierarchy, so+// the share sheet presents in the scene the user tapped in rather than a+// window picked from the app-wide scene set (T-1831).+//++import SwiftUI+#if os(iOS)+import UIKit+#endif++/// Scene-local presentation anchor threaded through `ExportNotesFlow` so+/// `InlineNotesShareHelper.share` presents in the window that actually+/// hosts the triggering control, never a window picked from the app-wide+/// scene set (T-1831).+///+/// `WebViewPool.selectIOSWindow(from:)` (T-745) scans+/// `UIApplication.shared.connectedScenes` and is correct for offscreen+/// WebView attachment, where the code explicitly documents that any scene+/// sharing the same activation state is interchangeable. That assumption+/// does not hold for user-facing presentation: `connectedScenes` is an+/// unordered `Set`, so with two foreground-active iPad scenes the same scan+/// can select the OTHER document's window and present the share sheet+/// there. This type carries a view mounted by `capturingScenePresentation`+/// into the SwiftUI hierarchy that actually hosts the button, so+/// presentation is pinned to the scene the user tapped in.+///+/// Meaningful only on iOS, where multiple foreground scenes can coexist.+/// Kept as a plain (view-less on macOS) type so `ExportNotesFlow` and+/// `InlineNotesShareHelper.share` share one signature across platforms —+/// macOS presentation already targets the key window directly and simply+/// ignores this.+@MainActor+final class ScenePresentationAnchor {+ #if os(iOS)+ /// The view mounted into the document screen's hierarchy by+ /// `ScenePresentationAnchorReader`. Weak: the anchor must never keep a+ /// view — or the window and scene behind it — alive past its own+ /// lifetime.+ ///+ /// The VIEW is stored rather than its window because a view's `window`+ /// is only set once UIKit has attached it, which is after the SwiftUI+ /// pass that creates the representable. Caching the window at that+ /// moment captured `nil` for the first run-loop turn after every mount+ /// and re-mount, and a share in that gap failed for a document that was+ /// on screen. Resolving through the view instead (`window` below) reads+ /// UIKit's current answer at share time.+ weak var hostView: UIView?++ /// The window currently hosting `hostView`, resolved on every read.+ /// `nil` only when no reader has attached yet, or when the view is+ /// genuinely detached from any window — a screen torn down while a+ /// share was in flight, not a screen still settling.+ var window: UIWindow? { hostView?.window }+ #endif++ init() {}+}++#if os(iOS)+/// Mounts an invisible view into the hierarchy and hands it to the anchor.+///+/// The anchor stores the VIEW, not its window, and resolves the window when+/// a share actually happens (`ScenePresentationAnchor.window`). A view's+/// `window` is only set once UIKit attaches it, which is after the SwiftUI+/// pass that creates or updates this representable; an earlier version+/// cached the window from a deferred `DispatchQueue.main.async` read, which+/// left it `nil` for the first run-loop turn after every mount and re-mount+/// (a document switch re-mounting the reader included), and a share in that+/// gap failed for a screen that was plainly on screen. Handing over the view+/// synchronously has no such gap: the only way the resolved window is `nil`+/// at share time is the view being genuinely detached, and UIKit clears a+/// removed view's `window` itself, so teardown needs no hook here. (The+/// macOS `HostingWindowFinder` keeps its deferred callback because it+/// publishes the window itself; this anchor publishes the view.)+private struct ScenePresentationAnchorReader: UIViewRepresentable {+ let anchor: ScenePresentationAnchor++ func makeUIView(context: Context) -> UIView {+ let view = UIView(frame: .zero)+ view.isHidden = true+ view.isUserInteractionEnabled = false+ anchor.hostView = view+ return view+ }++ func updateUIView(_ uiView: UIView, context: Context) {+ // Re-assert in case SwiftUI rebuilt the representable and the anchor+ // still points at a previous reader's view.+ anchor.hostView = uiView+ }+}++extension View {+ /// Attaches a `ScenePresentationAnchor` that tracks this view's hosting+ /// window (T-1831). Attach once, near the root of a document screen —+ /// every descendant control that shares that anchor (e.g. the+ /// Share-with-Notes button, however deep in the notes pane/sidebar it's+ /// mounted) presents into the same scene-correct window.+ func capturingScenePresentation(_ anchor: ScenePresentationAnchor) -> some View {+ background(ScenePresentationAnchorReader(anchor: anchor))+ }+}+#endif
diff --git a/prism/Services/InlineNotesShareHelper.swift b/prism/Services/InlineNotesShareHelper.swiftindex d9d9e973..9a7a1df8 100644--- a/prism/Services/InlineNotesShareHelper.swift+++ b/prism/Services/InlineNotesShareHelper.swift@@ -17,6 +17,20 @@ import AppKit private let logger = Logger.prism(category: "InlineNotesShareHelper") +/// Outcome of `InlineNotesShareHelper.share`. Each failure gets its own case+/// because they need different alerts: a write failure is about the+/// export file and may well persist, while a missing presentation window+/// says nothing about the export and is almost certainly transient.+enum ShareOutcome: Equatable {+ /// The share sheet was presented.+ case presented+ /// The export file could not be written.+ case exportFileUnavailable+ /// No window was available to present from (iOS: the scene-local anchor+ /// has no attached view; macOS: no application window).+ case presentationUnavailable+}+ enum InlineNotesShareHelper { /// Whether the Share-with-Notes action should be visible for the /// given session.@@ -40,20 +54,46 @@ enum InlineNotesShareHelper { } /// Exports inline notes to a temporary file and presents the platform share sheet.- /// Returns false if the export failed (e.g. file write error).+ /// Returns a `ShareOutcome` naming which step failed, if any. ///- /// - Parameter onComplete: Optional closure invoked when the share completes- /// successfully. On iOS this fires only when the user actually completes- /// the share (via `UIActivityViewController.completionWithItemsHandler`).- /// On macOS, `NSSharingServicePicker` does not provide a reliable completion- /// callback, so `onComplete` fires when the picker is presented (Decision 9).+ /// The presentation target is resolved BEFORE the export is rendered and+ /// written: a missing window would otherwise cost an O(document) render,+ /// leave a temp file behind, and be reported as a file failure it is not.+ ///+ /// - Parameters:+ /// - presentationAnchor: On iOS, the scene-local anchor whose hosting+ /// window the share sheet presents from (T-1831). There is+ /// deliberately no fallback to a global scene scan here: presenting+ /// from the wrong scene is exactly the bug this anchor exists to+ /// prevent, so a missing window fails the share rather than+ /// guessing. Ignored on macOS, which already targets the key window+ /// directly.+ /// - onComplete: Optional closure invoked when the share completes+ /// successfully. On iOS this fires only when the user actually completes+ /// the share (via `UIActivityViewController.completionWithItemsHandler`).+ /// On macOS, `NSSharingServicePicker` does not provide a reliable completion+ /// callback, so `onComplete` fires when the picker is presented (Decision 9). @MainActor @discardableResult static func share( notesManager: NotesManager, session: DocumentSession, settings: AppSettings,+ presentationAnchor: ScenePresentationAnchor, onComplete: (() -> Void)? = nil- ) -> Bool {+ ) -> ShareOutcome {+ #if os(iOS)+ guard let presenter = presentingViewController(for: presentationAnchor) else {+ logger.warning("InlineNotesShareHelper.share: no scene-local window available")+ return .presentationUnavailable+ }+ #elseif os(macOS)+ guard let window = NSApplication.shared.windows.first(where: \.isKeyWindow)+ ?? NSApplication.shared.windows.first else {+ logger.warning("InlineNotesShareHelper.share: no window available on macOS")+ return .presentationUnavailable+ }+ #endif+ let content = notesManager.exportWithInlineNotes( rawSource: session.content, blocks: session.parsedBlocks,@@ -67,8 +107,9 @@ enum InlineNotesShareHelper { try content.write(to: url, atomically: true, encoding: .utf8) } catch { logger.error("Failed to write export file: \(error.localizedDescription)")- return false+ return .exportFileUnavailable }+ #if os(iOS) let activityVC = UIActivityViewController(activityItems: [url], applicationActivities: nil) activityVC.completionWithItemsHandler = { _, completed, _, _ in@@ -76,22 +117,9 @@ enum InlineNotesShareHelper { Task { @MainActor in onComplete?() } } }- let scenes = UIApplication.shared.connectedScenes- .compactMap { $0 as? UIWindowScene }- guard let window = WebViewPool.selectIOSWindow(from: scenes),- let root = window.rootViewController else { return false }- var presenter = root- while let presented = presenter.presentedViewController {- presenter = presented- } presenter.present(activityVC, animated: true)- return true+ return .presented #elseif os(macOS)- guard let window = NSApplication.shared.windows.first(where: \.isKeyWindow)- ?? NSApplication.shared.windows.first else {- logger.warning("InlineNotesShareHelper.share: no window available on macOS")- return false- } let picker = NSSharingServicePicker(items: [url]) let contentView = window.contentView ?? NSView() picker.show(relativeTo: contentView.bounds, of: contentView, preferredEdge: .minY)@@ -99,10 +127,27 @@ enum InlineNotesShareHelper { // callback, so invoke onComplete after presenting (counter increments on // presentation rather than completion). onComplete?()- return true+ return .presented #endif } + #if os(iOS)+ /// The view controller the share sheet presents from: the topmost+ /// presented controller above the root of the window hosting the+ /// anchor's view, or `nil` when the anchor resolves to no window+ /// (T-1831). Split out of `share` so the scene-affinity contract can be+ /// tested by resolving, without presenting a real activity controller.+ @MainActor+ static func presentingViewController(for anchor: ScenePresentationAnchor) -> UIViewController? {+ guard let root = anchor.window?.rootViewController else { return nil }+ var presenter = root+ while let presented = presenter.presentedViewController {+ presenter = presented+ }+ return presenter+ }+ #endif+ // MARK: - Filename Sanitisation /// Base filename (no extension, no unique suffix) derived from a@@ -268,6 +313,15 @@ private struct ExportNotesFlowAlerts: ViewModifier { } message: { Text("Could not create the export file. Please try again.") }+ // Distinct from the write failure above: the export itself is+ // fine, only the scene-local window was unavailable (T-1831),+ // which is transient — a screen still settling — rather than a+ // persistent file problem.+ .alert("Share Unavailable", isPresented: $flow.showPresentationError) {+ Button("OK", role: .cancel) {}+ } message: {+ Text("The document window is not ready to show the share sheet. Please try again.")+ } } }
diff --git a/prism/Views/DocumentLayoutCoordinator.swift b/prism/Views/DocumentLayoutCoordinator.swiftindex 8abaf9c5..b7a0e381 100644--- a/prism/Views/DocumentLayoutCoordinator.swift+++ b/prism/Views/DocumentLayoutCoordinator.swift@@ -326,9 +326,20 @@ final class DocumentLayoutCoordinator { flow.onBanner = { [weak self] message in self?.bannerMessage = message }+ flow.presentationAnchor = presentationAnchor return flow }() + /// Tracks the window hosting this document screen (T-1831). Meaningful+ /// only on iOS, where multiple foreground scenes can coexist —+ /// `DocumentReaderView` attaches `.capturingScenePresentation(_:)` at the+ /// screen's own root so the anchor always reflects the SwiftUI view+ /// hierarchy this coordinator's screen lives in, never a window picked+ /// from the app-wide scene set. `exportNotesFlow` reads it (once,+ /// through the reference above) at share time so the share sheet always+ /// presents in this scene.+ let presentationAnchor = ScenePresentationAnchor()+ // MARK: - Session Reset /// Resets all session-scoped state. Call from `.onChange(of: session.id)`.@@ -770,8 +781,9 @@ final class ExportNotesFlow { _ notesManager: NotesManager, _ session: DocumentSession, _ settings: AppSettings,+ _ presentationAnchor: ScenePresentationAnchor, _ onComplete: (() -> Void)?- ) -> Bool+ ) -> ShareOutcome // MARK: Presentation state (alerts hosted by DocumentReaderView) @@ -781,9 +793,17 @@ final class ExportNotesFlow { /// Whether the username validation error alert is shown. var showValidationError = false - /// Whether the export failure alert is shown.+ /// Whether the export failure alert is shown (the export file could not+ /// be written). var showExportError = false + /// Whether the share-unavailable alert is shown: there was no window to+ /// present the share sheet from, checked before anything is exported+ /// (T-1831). Kept apart from `showExportError` because the two need+ /// different copy — this one is transient and says nothing about the+ /// export file.+ var showPresentationError = false+ /// Text field binding for the username prompt. var usernameInput = "" @@ -795,15 +815,25 @@ final class ExportNotesFlow { /// The share-sheet presenter. Mutable so unit tests can substitute a /// recorder without presenting UI; production code never reassigns it.- @ObservationIgnored var share: ShareAction = { notesManager, session, settings, onComplete in+ @ObservationIgnored var share: ShareAction = { notesManager, session, settings, presentationAnchor, onComplete in InlineNotesShareHelper.share( notesManager: notesManager, session: session, settings: settings,+ presentationAnchor: presentationAnchor, onComplete: onComplete ) } + /// This screen's scene-local presentation anchor (T-1831), assigned once+ /// by `DocumentLayoutCoordinator` when it creates this flow. Read at+ /// share time so the share sheet always targets the window this flow's+ /// screen is actually hosted in, never a window guessed from the+ /// app-wide scene set. Defaults to a window-less anchor so direct+ /// `ExportNotesFlow()` construction (previews, tests that never reach+ /// `share`) stays valid without wiring one up.+ @ObservationIgnored var presentationAnchor = ScenePresentationAnchor()+ /// Everything `run` captured when the username prompt was shown, consumed /// by `submitUsername()`. Kept across the validation-error "Try Again" /// loop; cleared on cancel.@@ -925,20 +955,31 @@ final class ExportNotesFlow { showUsernamePrompt = false showValidationError = false showExportError = false+ showPresentationError = false usernameInput = "" pendingShareContext = nil } private func performShare(_ context: ShareContext) { let storeManager = context.storeManager- let succeeded = share(context.notesManager, context.session, context.settings) { [weak self] in+ let outcome = share(+ context.notesManager,+ context.session,+ context.settings,+ presentationAnchor+ ) { [weak self] in storeManager.incrementExportCount() if let nudge = storeManager.postIncrementNudgeMessage() { self?.onBanner(nudge) } }- if !succeeded {+ switch outcome {+ case .presented:+ break+ case .exportFileUnavailable: showExportError = true+ case .presentationUnavailable:+ showPresentationError = true } } }
diff --git a/prism/Views/DocumentReaderView.swift b/prism/Views/DocumentReaderView.swiftindex 54d5144e..88c14271 100644--- a/prism/Views/DocumentReaderView.swift+++ b/prism/Views/DocumentReaderView.swift@@ -416,6 +416,13 @@ struct DocumentReaderView: View { .exportNotesFlowAlerts(coordinator.exportNotesFlow) .toast(message: Bindable(coordinator).bannerMessage) .themedBackground()+ #if os(iOS)+ // Keeps `coordinator.presentationAnchor` pointed at this screen's+ // own window, so Share-with-Notes always presents in the scene+ // the user tapped in rather than one picked from the app-wide+ // scene set (T-1831).+ .capturingScenePresentation(coordinator.presentationAnchor)+ #endif } }
diff --git a/prism/Localizable.xcstrings b/prism/Localizable.xcstringsindex 45336901..d4285bd1 100644--- a/prism/Localizable.xcstrings+++ b/prism/Localizable.xcstrings@@ -4509,6 +4509,29 @@ } } },+ "Share Unavailable": {+ "extractionState": "manual",+ "localizations": {+ "en": {+ "stringUnit": {+ "state": "translated",+ "value": "Share Unavailable"+ }+ },+ "en-GB": {+ "stringUnit": {+ "state": "translated",+ "value": "Share Unavailable"+ }+ },+ "en-US": {+ "stringUnit": {+ "state": "translated",+ "value": "Share Unavailable"+ }+ }+ }+ }, "Share image": { "extractionState": "manual", "localizations": {@@ -5199,6 +5222,29 @@ } } },+ "The document window is not ready to show the share sheet. Please try again.": {+ "extractionState": "manual",+ "localizations": {+ "en": {+ "stringUnit": {+ "state": "translated",+ "value": "The document window is not ready to show the share sheet. Please try again."+ }+ },+ "en-GB": {+ "stringUnit": {+ "state": "translated",+ "value": "The document window is not ready to show the share sheet. Please try again."+ }+ },+ "en-US": {+ "stringUnit": {+ "state": "translated",+ "value": "The document window is not ready to show the share sheet. Please try again."+ }+ }+ }+ }, "These notes couldn't be matched to current document content.": { "extractionState": "manual", "localizations": {
diff --git a/prismTests/InlineNotesShareHelperTests.swift b/prismTests/InlineNotesShareHelperTests.swiftindex 631822cc..0d08d5c9 100644--- a/prismTests/InlineNotesShareHelperTests.swift+++ b/prismTests/InlineNotesShareHelperTests.swift@@ -16,6 +16,10 @@ import Foundation import Testing+#if os(iOS)+import SwiftUI+import UIKit+#endif @testable import prism @Suite("InlineNotesShareHelper filename sanitisation")@@ -148,3 +152,178 @@ struct InlineNotesShareHelperTests { #expect(FileManager.default.fileExists(atPath: url.path)) } }++// MARK: - Scene-affinity (T-1831)++#if os(iOS)+/// T-1831 regression: `share` used to find its presentation window by+/// scanning `UIApplication.shared.connectedScenes` — correct for+/// `WebViewPool`'s offscreen WebView attachment (T-745, where any+/// equal-state scene is interchangeable), but not for user-facing+/// presentation. With two foreground-active iPad scenes, that global scan+/// could select the OTHER document's window and present the share sheet+/// there. `share` now takes a `ScenePresentationAnchor` and presents through+/// the window hosting its captured view ONLY — no global fallback.+///+/// The contract is pinned at the resolution seam+/// (`InlineNotesShareHelper.presentingViewController(for:)`) rather than by+/// presenting a real `UIActivityViewController` from a scene-less window in+/// the test host. The two windows below need no `UIWindowScene`: the point+/// is which one the anchor resolves to, and that neither the other window+/// nor a global scan is consulted.+///+/// Every test is `async` so the `@MainActor` hop is part of the ABI: a+/// synchronous `@MainActor` body gets no hop-on-entry in this target's build+/// configuration, and UIKit's window calls assert off-main just as WebKit's+/// do (T-2219).+@Suite("InlineNotesShareHelper scene-affine presentation")+@MainActor+struct InlineNotesShareHelperScenePresentationTests {+ /// Distinct per suite instance so a leftover export file from one run+ /// cannot be mistaken for one written by another.+ private let documentName = "share-scene-affinity-\(UUID().uuidString.prefix(8)).md"++ private func makeSession() -> DocumentSession {+ DocumentSession(+ url: URL(fileURLWithPath: "/tmp/\(documentName)"),+ content: "# Title\n\nBody."+ )+ }++ private func makeNotesManager() -> NotesManager {+ NotesManager.makeForTesting(store: MockNotesStore())+ }++ private func makeWindow() -> UIWindow {+ let window = UIWindow(frame: .zero)+ window.rootViewController = UIViewController()+ return window+ }++ private func share(through anchor: ScenePresentationAnchor) -> ShareOutcome {+ InlineNotesShareHelper.share(+ notesManager: makeNotesManager(),+ session: makeSession(),+ settings: AppSettings(),+ presentationAnchor: anchor+ )+ }++ /// Export files this suite's `share` calls would have written, by the+ /// sanitised base name `temporaryExportURL` derives from `documentName`.+ private func exportFilesWritten() throws -> [String] {+ let base = InlineNotesShareHelper.sanitizedBaseName(from: documentName)+ return try FileManager.default+ .contentsOfDirectory(atPath: FileManager.default.temporaryDirectory.path)+ .filter { $0.hasPrefix(base) }+ }++ @Test("share reports the window as unavailable, writing nothing, when its anchor has no view")+ func shareFailsWithoutAWindow() async throws {+ // No global fallback: an anchor that no reader has attached to must+ // not fall back to guessing some other connected scene's window. The+ // outcome names the missing window, not the export file — and the+ // window is checked BEFORE the export is rendered, so nothing is+ // written for a share that cannot be presented.+ let anchor = ScenePresentationAnchor()++ #expect(share(through: anchor) == .presentationUnavailable)+ #expect(try exportFilesWritten().isEmpty)+ }++ /// Review edge case: the reader hands its view to the anchor during the+ /// SwiftUI pass that creates it, which is BEFORE UIKit attaches the view+ /// to a window. The window must therefore be resolved at share time from+ /// the view's current hosting, not cached at capture time — a cached+ /// `nil` failed the share for a document that was on screen.+ @Test("a view captured before it is attached resolves its window at share time")+ func anchorCapturedBeforeWindowAttachmentResolvesLazily() async throws {+ let window = makeWindow()+ let host = UIView(frame: .zero)+ let anchor = ScenePresentationAnchor()++ // Captured while detached, exactly as `makeUIView` does it.+ anchor.hostView = host+ #expect(anchor.window == nil)+ #expect(InlineNotesShareHelper.presentingViewController(for: anchor) == nil)+ #expect(share(through: anchor) == .presentationUnavailable)+ #expect(try exportFilesWritten().isEmpty)++ // UIKit attaches the view afterwards; nothing touches the anchor.+ window.addSubview(host)+ #expect(anchor.window === window)+ #expect(InlineNotesShareHelper.presentingViewController(for: anchor) === window.rootViewController)+ }++ @Test("a view removed from its window reads as genuinely detached")+ func detachedViewFailsTheShare() async throws {+ let window = makeWindow()+ let host = UIView(frame: .zero)+ window.addSubview(host)+ let anchor = ScenePresentationAnchor()+ anchor.hostView = host+ #expect(anchor.window === window)++ host.removeFromSuperview()++ #expect(anchor.window == nil)+ #expect(share(through: anchor) == .presentationUnavailable)+ #expect(try exportFilesWritten().isEmpty)+ }++ @Test("the presenter resolves to the anchor's own window, never a different scene's window")+ func presenterResolvesOnlyToTheAnchorsOwnWindow() async {+ // Two windows standing in for two foreground-active iPad scenes.+ let windowA = makeWindow()+ let windowB = makeWindow()+ let hostA = UIView(frame: .zero)+ windowA.addSubview(hostA)+ let anchorA = ScenePresentationAnchor()+ anchorA.hostView = hostA++ let presenter = InlineNotesShareHelper.presentingViewController(for: anchorA)++ #expect(presenter === windowA.rootViewController, "must present in the anchor's own window")+ #expect(presenter !== windowB.rootViewController, "a different scene's window must never be chosen")+ }++ /// End-to-end through the real modifier, on a window attached to the+ /// test host's scene (the `SystemColorSchemeObserverTests` precedent):+ /// mounting a SwiftUI view carrying `.capturingScenePresentation` in a+ /// hosting controller leaves the anchor resolving to THAT window, and+ /// re-hosting the controller in another window follows it — with no+ /// deferred read to wait for, since the anchor holds the view.+ @Test("the reader modifier captures the hosting window and follows a re-host")+ func readerCapturesHostingWindow() async throws {+ let scene = try #require(+ UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first,+ "the test host should have a connected window scene"+ )+ let anchor = ScenePresentationAnchor()+ let hosting = UIHostingController(rootView: Color.clear.capturingScenePresentation(anchor))++ let windowA = UIWindow(windowScene: scene)+ let windowB = UIWindow(windowScene: scene)+ defer {+ // Never key, so hiding is all the cleanup the host's own window+ // needs; dropping the root lets the hosting view detach.+ for window in [windowA, windowB] {+ window.rootViewController = nil+ window.isHidden = true+ }+ }++ windowA.rootViewController = hosting+ windowA.isHidden = false+ windowA.layoutIfNeeded()+ #expect(hosting.view.window === windowA)+ #expect(anchor.window === windowA)++ windowB.rootViewController = hosting+ windowB.isHidden = false+ windowB.layoutIfNeeded()+ #expect(hosting.view.window === windowB)+ #expect(anchor.window === windowB)+ }+}+#endif
diff --git a/prismTests/ExportNotesFlowTests.swift b/prismTests/ExportNotesFlowTests.swiftindex ed7cb2a3..d3a55c6c 100644--- a/prismTests/ExportNotesFlowTests.swift+++ b/prismTests/ExportNotesFlowTests.swift@@ -72,18 +72,24 @@ struct ExportNotesFlowTests { } /// Records share invocations without presenting any UI. `result` mimics- /// the share sheet failing to present (file write error); when- /// `invokesCompletion` is true the stub reports a completed share- /// synchronously, like the macOS presentation path.+ /// the helper's outcome (file write error, or no window to present+ /// from); when `invokesCompletion` is true the stub reports a completed+ /// share synchronously, like the macOS presentation path.+ ///+ /// Also records the `presentationAnchor` each call received (T-1831),+ /// so tests can confirm a flow always shares through its OWN scene's+ /// anchor rather than a shared or guessed one. @MainActor private final class ShareRecorder { private(set) var callCount = 0- var result = true+ private(set) var receivedAnchors: [ScenePresentationAnchor] = []+ var result: ShareOutcome = .presented var invokesCompletion = true func install(on flow: ExportNotesFlow) {- flow.share = { _, _, _, onComplete in+ flow.share = { _, _, _, presentationAnchor, onComplete in self.callCount += 1+ self.receivedAnchors.append(presentationAnchor) if self.invokesCompletion { onComplete?() }@@ -209,6 +215,67 @@ struct ExportNotesFlowTests { #expect(store.exportCount == 1) } + // MARK: - Scene affinity (T-1831)++ /// T-1831 regression: `InlineNotesShareHelper.share` used to find its+ /// presentation window by scanning `UIApplication.shared.connectedScenes`+ /// globally. With two foreground-active iPad scenes open side by side —+ /// each hosting its own `DocumentLayoutCoordinator` — that scan could+ /// select either scene's window, so a share triggered in one document+ /// could present in the other. Two independent coordinators (standing in+ /// for two document scenes) must never share a `presentationAnchor`+ /// instance, and each one's flow must forward exactly ITS OWN anchor to+ /// the share sheet — never the other scene's, and never one conjured+ /// from a global lookup.+ @Test("two document screens' export flows carry independent presentation anchors")+ @MainActor+ func twoScenesCarryIndependentPresentationAnchors() {+ defer { clearUsername() }+ let coordinatorA = DocumentLayoutCoordinator()+ let coordinatorB = DocumentLayoutCoordinator()++ // Each scene's coordinator owns a distinct anchor instance...+ #expect(coordinatorA.presentationAnchor !== coordinatorB.presentationAnchor)+ // ...and wires that exact instance into its own flow, not the other+ // scene's.+ #expect(coordinatorA.exportNotesFlow.presentationAnchor === coordinatorA.presentationAnchor)+ #expect(coordinatorB.exportNotesFlow.presentationAnchor === coordinatorB.presentationAnchor)+ #expect(coordinatorA.exportNotesFlow.presentationAnchor !== coordinatorB.presentationAnchor)+ }++ @Test("sharing from one scene's flow presents through that scene's own anchor")+ @MainActor+ func shareUsesItsOwnScenesPresentationAnchor() {+ defer { clearUsername() }+ let settings = makeSettings(username: "Reviewer")+ let paywall = PaywallPresenter()+ let store = makeStore()++ // Two document screens open side by side, as on a two-scene iPad.+ let coordinatorA = DocumentLayoutCoordinator()+ let coordinatorB = DocumentLayoutCoordinator()+ let flowA = coordinatorA.exportNotesFlow+ let shareA = ShareRecorder()+ shareA.install(on: flowA)+ let shareB = ShareRecorder()+ shareB.install(on: coordinatorB.exportNotesFlow)++ flowA.run(+ notesManager: makeNotesManager(),+ session: makeSession(),+ settings: settings,+ storeManager: store,+ paywall: paywall+ )++ // Scene A shared exactly once, through scene A's own anchor...+ #expect(shareA.callCount == 1)+ #expect(shareA.receivedAnchors.first === coordinatorA.presentationAnchor)+ #expect(shareA.receivedAnchors.first !== coordinatorB.presentationAnchor)+ // ...and scene B's export was never touched.+ #expect(shareB.callCount == 0)+ }+ // MARK: - Blocked gate (Req 2.4) @Test("blocked gate stores the retry in pendingExportAction and returns .blocked")@@ -339,7 +406,7 @@ struct ExportNotesFlowTests { let coordinator = DocumentLayoutCoordinator() let flow = coordinator.exportNotesFlow let share = ShareRecorder()- share.result = false+ share.result = .exportFileUnavailable share.invokesCompletion = false share.install(on: flow) @@ -352,10 +419,49 @@ struct ExportNotesFlowTests { ) #expect(flow.showExportError)+ #expect(!flow.showPresentationError) #expect(store.exportCount == 0) #expect(coordinator.bannerMessage == nil) } + /// T-1831 review: a missing presentation window is not an export+ /// failure — the presenter is resolved before anything is written, so+ /// no file exists — and it must not surface as the+ /// "Export Failed" alert, which tells the user the export file could+ /// not be created and would be false. It gets its own, transient-sounding+ /// alert instead.+ @Test("missing presentation window sets the share-unavailable state, not the export-error state")+ @MainActor+ func missingWindowSetsPresentationErrorState() {+ defer { clearUsername() }+ let settings = makeSettings(username: "Reviewer")+ let paywall = PaywallPresenter()+ let store = makeStore()+ let coordinator = DocumentLayoutCoordinator()+ let flow = coordinator.exportNotesFlow+ let share = ShareRecorder()+ share.result = .presentationUnavailable+ share.invokesCompletion = false+ share.install(on: flow)++ flow.run(+ notesManager: makeNotesManager(),+ session: makeSession(),+ settings: settings,+ storeManager: store,+ paywall: paywall+ )++ #expect(flow.showPresentationError)+ #expect(!flow.showExportError)+ #expect(store.exportCount == 0)+ #expect(coordinator.bannerMessage == nil)++ // `reset()` (document switch) clears it like every other alert flag.+ flow.reset()+ #expect(!flow.showPresentationError)+ }+ // MARK: - Nudge routing (coordinator banner) @Test("nudge after a completed share routes to the coordinator banner")
diff --git a/specs/bugfixes/ipad-share-scene-affinity/report.md b/specs/bugfixes/ipad-share-scene-affinity/report.mdnew file mode 100644index 00000000..e4249e25--- /dev/null+++ b/specs/bugfixes/ipad-share-scene-affinity/report.md@@ -0,0 +1,224 @@+# Bugfix Report: iPad Share with Notes Can Present in the Wrong Document Scene++**Ticket:** T-1831+**Date:** 2026-09-06+**Status:** Fixed++## Description of the Issue++`InlineNotesShareHelper.share` located a window to present the share sheet+from by scanning `UIApplication.shared.connectedScenes` and picking the+"best" one via `WebViewPool.selectIOSWindow(from:)`. That selection rule was+designed for `WebViewPool`'s offscreen WebView attachment (T-745), where any+scene sharing the same activation state is documented as interchangeable.+With two foreground-active iPad scenes open side by side, `connectedScenes`+is an unordered `Set`, so the same scan could return either scene's window —+tapping "Share with Notes" in one document window could present the+activity sheet in the other window instead.++**Reproduction steps:**+1. Open two Prism documents side by side on iPad (two foreground-active+ scenes).+2. Tap "Share with Notes" in scene B while both scenes are foreground-active.+3. Depending on scene ordering in `connectedScenes`, the share sheet can+ appear in scene A instead of scene B.++**Impact:** iPad multi-window users only. The share sheet presenting in the+wrong window is confusing and, since the exported file is the triggering+document's notes, could look like it belongs to the other open document.++## Investigation Summary++- **Symptoms examined:** `InlineNotesShareHelper.swift:79-87` builds the+ `UIActivityViewController` and picks a window via the exact same call+ (`WebViewPool.selectIOSWindow(from:)`) that `WebViewPool` uses for+ offscreen WebView attachment.+- **Code inspected:** `WebViewPool.swift:460-497`'s doc comment on+ `selectIOSWindow` explicitly states same-state scenes are treated as+ interchangeable — a correct assumption for attaching an invisible+ rendering surface, but not for a user-visible presentation that must+ target the scene the user is actually looking at.+ `DocumentLayoutCoordinator.swift` (`ExportNotesFlow.performShare`) invokes+ the helper with no scene/window context at all.+- **Hypotheses tested:** Confirmed via T-745's own dedup note that the+ underlying nondeterminism is known and accepted for offscreen rendering,+ but was never scoped away from `InlineNotesShareHelper`'s later,+ user-facing use of the same selector.++## Discovered Root Cause++**Defect type:** Wrong selection scope — a helper designed for interchangeable+background targets was reused for a presentation that must be scene-affine.++**Why it occurred:** `InlineNotesShareHelper.share` needed *some* window and+reached for the one selector already in the codebase+(`WebViewPool.selectIOSWindow`), without noticing that selector's+documented nondeterminism guarantee ("equally viable", not "the caller's own+scene") does not hold for imperative UI presentation.++**Contributing factors:** `ExportNotesFlow`/`DocumentLayoutCoordinator` had+no existing mechanism for tracking which window/scene a document screen is+hosted in — `PaywallPresenter` avoids the whole class of bug by using SwiftUI+declarative `.sheet(isPresented:)`, which is inherently scoped to the view+hierarchy it is attached to, but `UIActivityViewController` requires+imperative `UIViewController.present`, which has no such built-in scoping.++## Resolution for the Issue++**Changes made:**+- `prism/Views/ScenePresentationAnchor.swift` (new) - `ScenePresentationAnchor`,+ a small `@MainActor` class holding a `weak var hostView: UIView?` (iOS+ only) with `window` resolved from it on every read, plus the+ `UIViewRepresentable`-backed `.capturingScenePresentation(_:)` modifier+ that mounts that view and hands it to the anchor synchronously. Holding+ the view rather than a cached window is what closes the review's edge+ case: a view's `window` is set only after UIKit attaches it, so a window+ cached during the SwiftUI pass (the first version used a deferred+ `DispatchQueue.main.async` read) was `nil` for a run-loop turn after every+ mount and re-mount, and a share in that gap failed for a screen that was+ on screen. Resolving at share time means `nil` only when the view is+ genuinely detached.+- `prism/Services/InlineNotesShareHelper.swift` - `share(...)` takes a+ required `presentationAnchor`, resolves the presenter through+ `presentingViewController(for:)` (topmost presented controller above the+ anchor window's root) BEFORE rendering and writing the export — a missing+ window must not cost an O(document) render or leak a temp file — and+ presents there only, with no fallback to a global scene scan. It returns a+ `ShareOutcome` so the two failures get their own alerts.+- `prism/Views/DocumentReaderView.swift` - attaches+ `.capturingScenePresentation(coordinator.presentationAnchor)` at the+ document screen's root (iOS only), so the anchor always reflects that+ screen's own window.+- `prism/Views/DocumentLayoutCoordinator.swift` - `DocumentLayoutCoordinator`+ now owns a `presentationAnchor` (`ScenePresentationAnchor`), wired into its+ lazily-created `exportNotesFlow`. `ExportNotesFlow.ShareAction` gained a+ `presentationAnchor` parameter; `ExportNotesFlow.presentationAnchor` is+ read at share time and threaded into `InlineNotesShareHelper.share`.+ `performShare` maps `.presentationUnavailable` to a new+ `showPresentationError` flag with its own "Share Unavailable" alert (the+ window was not ready — transient), distinct from `showExportError`'s+ "Export Failed" (the file could not be written).+ `WebViewPool.selectIOSWindow` is untouched and remains in use for its+ original, offscreen-rendering purpose only.++**Approach rationale:** This follows the pattern `PaywallPresenter`+established for T-1779 (per-scene presentation state) while accounting for+the fact that `UIActivityViewController` presentation is imperative, not+declarative - so instead of a SwiftUI `@State` sheet flag, the fix threads a+scene-captured window reference through the same object graph+(`DocumentLayoutCoordinator` -> `ExportNotesFlow` -> `InlineNotesShareHelper`)+that already carries the per-screen `PaywallPresenter`.++**Alternatives considered:**+- **Pass a `UIViewController`/hosting-controller reference instead of a+ window** - rejected: the button's own view has no fixed relationship to a+ specific `UIViewController` (it's SwiftUI content composed inside+ changing container views), whereas the hosting `UIWindow` is stable for+ the life of the scene and is all `share()` already needed+ (`window.rootViewController`).+- **Give `WebViewPool.selectIOSWindow` a "prefer this scene" parameter and+ pass the coordinator's own scene identifier** - rejected: it would keep+ presentation coupled to a selector explicitly designed around+ interchangeability, and still requires a scene-local anchor as an input,+ so it adds indirection without removing the coupling the ticket asked to+ remove.+- **Cache the window in the anchor (deferred read, or a `didMoveToWindow`+ override on the reader's view)** - rejected: the deferred read is the+ timing gap described above, and a `didMoveToWindow` cache is exact but+ duplicates state UIKit already keeps on the view. Holding the view and+ reading `view.window` at share time is the same information with no+ cache to keep in step. The macOS `HostingWindowFinder` keeps its deferred+ callback because it publishes the window itself; it is macOS-only and+ callback-shaped, so it was not reused.++## Regression Test++**Test file:** `prismTests/ExportNotesFlowTests.swift`,+`prismTests/InlineNotesShareHelperTests.swift`++**Test names:**+- `ExportNotesFlowTests.twoScenesCarryIndependentPresentationAnchors`+- `ExportNotesFlowTests.shareUsesItsOwnScenesPresentationAnchor`+- `ExportNotesFlowTests.missingWindowSetsPresentationErrorState`+- `InlineNotesShareHelperScenePresentationTests.shareFailsWithoutAWindow`+- `InlineNotesShareHelperScenePresentationTests.anchorCapturedBeforeWindowAttachmentResolvesLazily`+- `InlineNotesShareHelperScenePresentationTests.detachedViewFailsTheShare`+- `InlineNotesShareHelperScenePresentationTests.presenterResolvesOnlyToTheAnchorsOwnWindow`+- `InlineNotesShareHelperScenePresentationTests.readerCapturesHostingWindow`++**What it verifies:** Two independent `DocumentLayoutCoordinator` instances+(standing in for two document scenes) never share a `presentationAnchor`+instance, `ExportNotesFlow` forwards exactly its own anchor to the share+seam, and a `.presentationUnavailable` outcome raises the share-unavailable+alert rather than the export-error one. At the+`InlineNotesShareHelper.presentingViewController(for:)` seam, an anchor+resolves only to the window hosting its own view — never a second,+independent window standing in for another scene — and it resolves lazily:+a view captured before UIKit attached it (the order `makeUIView` sees)+fails cleanly, then resolves once attached, and reads as detached again+once removed. The no-window failures also assert that no export file was+written. `readerCapturesHostingWindow` drives the real+`.capturingScenePresentation` modifier through a `UIHostingController` in a+window attached to the test host's scene and checks the anchor follows a+re-host.++**Not exercised by automated tests:** the two-scene iPad path itself (the+test host provides one `UIWindowScene` and a test cannot connect another),+and the iOS `.presented` branch that constructs and presents the real+`UIActivityViewController` — the resolution seam is tested instead of+presenting a live activity controller from a scene-less window. The+iOS-only suite compiles out on the macOS destination, so it runs under+`make test` (Simulator), not `make test-quick`.++**Run command:**+```+xcodebuild test -project prism.xcodeproj -scheme prism \+ -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \+ -only-testing:prismTests/ExportNotesFlowTests \+ -only-testing:prismTests/InlineNotesShareHelperTests \+ -only-testing:prismTests/InlineNotesShareHelperScenePresentationTests test+```++## Affected Files++| File | Change |+|------|--------|+| `prism/Services/InlineNotesShareHelper.swift` | `share(...)` takes a required anchor, resolves the presenter before writing, drops the global scene scan, returns `ShareOutcome`; new `presentingViewController(for:)` seam; "Share Unavailable" alert |+| `prism/Views/ScenePresentationAnchor.swift` | New: `ScenePresentationAnchor` (weak host view, window resolved on read) and the `.capturingScenePresentation(_:)` modifier |+| `prism/Localizable.xcstrings` | "Share Unavailable" alert title and message |+| `prism/Views/DocumentReaderView.swift` | Attaches the anchor-capturing modifier at the document screen root (iOS) |+| `prism/Views/DocumentLayoutCoordinator.swift` | `DocumentLayoutCoordinator.presentationAnchor`; `ExportNotesFlow.presentationAnchor`, `ShareAction` returns `ShareOutcome`, `showPresentationError` |+| `prismTests/ExportNotesFlowTests.swift` | `ShareRecorder` records the received anchor and returns a `ShareOutcome`; scene-affinity and distinct-alert tests |+| `prismTests/InlineNotesShareHelperTests.swift` | New `InlineNotesShareHelperScenePresentationTests` suite (resolution seam, lazy attachment, reader end-to-end) |++## Verification++**Automated:**+- [x] Regression tests pass (`ExportNotesFlowTests`, `InlineNotesShareHelperTests`, `InlineNotesShareHelperScenePresentationTests`, `PaywallPresenterTests`)+- [ ] Full test suite (`make test-quick`/`make test`) - not run; multiple fix agents were running concurrently on this machine and the full suite is unreliable under contention per project convention. Verified instead with `make lint`, `make build-ios`, `make build-macos`, and the targeted suites above via `Tools/check-test-results.sh`.+- [x] Linters/validators pass (`make lint`)++**Manual verification:** Not performed (no two-scene iPad hardware/simulator+session driven interactively); relies on the automated scene-affinity tests+above, which exercise the exact object-graph seam the bug lived in. The+two-scene path and the live share-sheet presentation remain manual checks.++## Prevention++**Recommendations to avoid similar bugs:**+- Any new imperative UIKit presentation (share sheets, alerts built outside+ SwiftUI, etc.) reached from a document screen should take an explicit+ `ScenePresentationAnchor` (or equivalent) rather than reusing+ `WebViewPool.selectIOSWindow`, which is documented as being for+ interchangeable offscreen targets only.+- Prefer SwiftUI declarative presentation (`.sheet`, `.alert`) over+ imperative `UIViewController.present` where possible - it is scene-safe by+ construction, as `PaywallPresenter` demonstrates.++## Related++- T-745: introduced `WebViewPool.selectIOSWindow` and its+ interchangeable-scene contract for offscreen WebView attachment.+- T-1779: `PaywallPresenter` per-scene presentation ownership, the pattern+ this fix follows for an imperative (rather than declarative) presentation+ path.
diff --git a/docs/agent-notes/export-inline-notes.md b/docs/agent-notes/export-inline-notes.mdindex efc7219c..38592dd6 100644--- a/docs/agent-notes/export-inline-notes.md+++ b/docs/agent-notes/export-inline-notes.md@@ -166,10 +166,10 @@ The modifier is defined in `InlineNotesShareHelper.swift` as `InlineNotesExportT **T-1154 (2026-05-12)**: `hasActiveAnchoredNotes(in:)` now builds its valid-keys set from each block's top-level ID plus list-item IDs (`block.allListItemIds()`) and table-row IDs (`block.allTableRowIds()`), so sub-block anchored notes correctly satisfy the Share-with-Notes gate. Orphan exclusion (Decision 19) is preserved. `InlineNotesShareHelper.share()` has platform-specific implementations:-- iOS: `UIActivityViewController` presented from the key window's root view controller-- macOS: `NSSharingServicePicker` shown relative to the content view bounds of the first key window (falls back to first available window). Uses `NSApplication.shared.windows` instead of the deprecated `NSApp.keyWindow`.+- iOS: `UIActivityViewController` presented from the topmost view controller of the window hosting the screen's `ScenePresentationAnchor` (T-1831, `prism/Views/ScenePresentationAnchor.swift`). The anchor holds the reader's `UIView` weakly and resolves `view.window` at share time, so it is never stale and never guessed from `UIApplication.shared.connectedScenes` — there is deliberately no global fallback; with two iPad scenes that scan could present in the other document's window. `DocumentReaderView` attaches `.capturingScenePresentation(coordinator.presentationAnchor)` at the screen root; `DocumentLayoutCoordinator` owns the anchor and hands it to `ExportNotesFlow`, which passes it to `share`. `presentingViewController(for:)` is the testable resolution seam.+- macOS: `NSSharingServicePicker` shown relative to the content view bounds of the first key window (falls back to first available window). Uses `NSApplication.shared.windows` instead of the deprecated `NSApp.keyWindow`; the anchor is ignored. -Both write the exported content to a temp `.md` file first, then share the file URL.+Both resolve the presentation target FIRST, then write the exported content to a temp `.md` file and share the file URL — a missing window must not cost a render or leave a temp file behind. `share` returns a `ShareOutcome`; `ExportNotesFlow.performShare` maps `.exportFileUnavailable` to the "Export Failed" alert and `.presentationUnavailable` to the separate "Share Unavailable" alert, since the latter is transient and says nothing about the export file. ## Thread Grouping: Dual Code Paths (T-512, 2026-03-21)
diff --git a/docs/agent-notes/webview-pool.md b/docs/agent-notes/webview-pool.mdindex bccf6a2f..f0198817 100644--- a/docs/agent-notes/webview-pool.md+++ b/docs/agent-notes/webview-pool.md@@ -39,7 +39,7 @@ WebViewPool is fully implemented. `SVGRenderer` and `BackgroundDiagramRenderer` - Prioritises foreground-active scenes, then foreground-inactive, then background; skips `.unattached` - Within each scene, prefers key window (`isKeyWindow`) over arbitrary first window - `WindowSceneProviding` protocol abstracts `UIWindowScene` for unit testing via `MockWindowScene`/`MockWindow`-- Other call sites (`MermaidRenderer.attachToWindowLegacy`, `InlineNotesShareHelper`) also use this method+- `MermaidRenderer.attachToWindowLegacy` also uses this method. `InlineNotesShareHelper` no longer does (T-1831): the selector's "any equal-state scene is interchangeable" contract is right for offscreen attachment and wrong for a user-facing share sheet, which now presents through a `ScenePresentationAnchor` captured from the document screen's own view hierarchy (`prism/Views/ScenePresentationAnchor.swift`) - Never use `.first` on `UIApplication.shared.connectedScenes` directly — it's non-deterministic (`Set` ordering) ## Attachment revalidation (T-2133)
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 9f8806be..d209ebd4 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Share with Notes on iPad no longer risks presenting in a different document window (T-1831). With two document scenes open side by side, the share sheet found its presentation window by scanning the app's global scene set — a selection rule built for offscreen WebView rendering, where the docs explicitly say any scene in the same state is interchangeable, but wrong for a share sheet the user expects to see in the window they tapped from. That scan could return either scene's window, so tapping Share with Notes in one document could pop the sheet up in the other. The share flow now carries a presentation anchor captured from the document screen's own window, threaded through the same per-screen object that already keeps the paywall scoped correctly (T-1779), and no longer falls back to the global scene scan at all. The anchor resolves its window at the moment of sharing, so a screen that has only just appeared or switched documents is not mistaken for one with no window; and in the rare case the window really is unavailable, the alert now says so ("Share Unavailable") instead of claiming the export file could not be created. - The shared unit-test host no longer aborts part-way through a run and reports every still-queued test as a failure it never ran (T-2219, third pass). PR #380 fixed one cause of this — a synchronous `@MainActor` test body reaching WebKit off the main thread — and the cascade kept coming back with `make verify-test-isolation` passing, because there was a second, unrelated cause on a lifetime path no constructor check can see. A crash report captured during this investigation named it: WebKit raises an Objective-C exception on a Swift async job on the main thread, and the exception unwinds into a Swift frame, where `_swift_exceptionPersonality` calls `swift::fatalError` and aborts **inside the throw**. That last detail is why the failure had been so expensive to chase: the process dies before `NSSetUncaughtExceptionHandler` or any `catch` can see it, so nothing is recorded, the exception's own text is destroyed with the process, and the test the bundle blames is simply whichever job was resumed at that instant — twice it blamed `URLEncodingCorpusTests`, which does not touch WebKit at all. The path in this repository that can reach that stack is the `prism-doc://` scheme handler: it produced responses from an unstructured task into an unbounded stream buffer, so WebKit stopping a task (navigating away, superseding a load, releasing a page — all routine) raced every response not yet delivered, and a `WKURLSchemeTask` given anything after it has been stopped raises exactly that exception. Three of its four production points had no cancellation check at all. Every one of them now goes through a single sink that stops producing as soon as its consumer is gone, failures included, and a source-contract test fails the build if a new one bypasses it — a behavioural test is impossible here, since reproducing the race aborts the process running it. That sink is hardening rather than a closed door, and the code says so: its cancellation signal arrives only once the stream is already torn down, so it narrows the window instead of removing it. A bounded stream buffer is not the missing piece — `AsyncStream` has no back-pressure at any policy, so bounding it would drop response and body elements rather than slow the producer down. - Live-WebKit tests no longer hold hundreds of WebKit processes open at once (T-2219). Measured on the run that reproduced the abort: 230 WebKit helper processes started by one test host, 226 of them alive simultaneously in the instant it died, only 4 ever reclaimed — about 206 concurrent live pages. `-parallel-testing-worker-count 1` does not bound this; it bounds test host processes, while swift-testing runs tests concurrently inside one host with no cap, and the live-WebKit tests are the slowest in the target, so they are precisely the ones that accumulate. Every clean run peaked in the same place, so the pile-up is not itself the crash — it is the condition the crash needs, and it is why a run only fails under load and never reproduces a suite in isolation. Suites that can hold a live page are now charged against a shared budget, which took the peak from 226 to 59 and restored reclamation during the run. `make verify-test-isolation` fails when a suite that can reach WebKit is not covered, sharing one reachability model with the existing synchronous-construction rule so a suite cannot be visible to one check and invisible to the other; it found an uncovered suite on `main` the first time it ran, and eight more once `WebViewPool` and `SVGRenderer` were added to the list of production types it treats as building a page. That list is the boundary of both checks and is documented as such: a production type that builds a page but is not named there is invisible to them, and nothing can discover the omission automatically. Nothing is skipped, excluded or reordered, and the number of tests executed is unchanged. - `Tools/check-test-results.sh` now tells you what to look for when it detects the cascade (T-2219). It used to point at `~/Library/Logs/DiagnosticReports/prism-*.ips` and stop there. Those reports are frequently never written — three consecutive reproductions on the development machine produced none — and they rotate away within days, which is how this ticket twice lost the only evidence it had. The message now names the stack signature that identifies this abort, so a report that does exist can be read correctly on the first attempt, and states that there is no in-process alternative to it.
Verified in the working tree, not just the diff: InlineNotesShareHelper.swift:84-97 returns .presentationUnavailable on both platforms before exportWithInlineNotes (line 99) and the temp-file write (108-113). The production ordering is right; only the test that claims to pin it is vacuous (Finding 1).
ScenePresentationAnchorReader.swift (deferred DispatchQueue.main.async window capture) is deleted; the anchor now holds weak var hostView and computes window. Not @Observable, coordinator property is a let, flow property is @ObservationIgnored — no invalidation feedback from the per-update weak store.
No test presents a live UIActivityViewController; own-window resolution is asserted at presentingViewController(for:). documentName carries a per-instance UUID. Every iOS test is async in a @MainActor suite; Tools/check-webkit-test-isolation.py passes and no new suite reaches a PRODUCTION_WEBKIT_TYPES entry, so no .liveWebKit is owed. The remaining hygiene point is the two visible windows left on the host scene (Finding 2).
docs/agent-notes/export-inline-notes.md and webview-pool.md describe the new path accurately; dependency-injection.md, inapp-purchase.md, typography-font-settings.md remain true. Remaining hits are point-in-time spec artefacts (review-overview-*.md, implementation.md) and should be left alone. CLAUDE.md's Source Structure omits the new file (Finding 6).
"Share Unavailable" and "The document window is not ready to show the share sheet. Please try again." exist in Localizable.xcstrings byte-identically, extractionState: manual, en/en-GB/en-US — the same shape as the sibling "Export Failed" entries. Literal .alert("…") / Text("…") forms are the preferred pattern; no en-AU divergence needed.
A no-window macOS share used to return false → "Export Failed"; it now returns .presentationUnavailable → "Share Unavailable". Deliberate and more accurate; no macOS test asserted the old copy. Effectively unreachable in the app.
The report is honest that the two-scene iPad path and the live .presented branch are not automated (the test host has one UIWindowScene). Worth a two-window simulator pass before release, as the report itself says.