Fix document opening constraint crash with docked inspector panel
6 files changed,
+192
−12
native/apple/Sources/DocsKit/DocsWorkspace.swift+20 −12native/scripts/test-document-window.py+49 −0native/smoke/document-window/Package.resolved+23 −0native/smoke/document-window/Package.swift+7 −0native/smoke/document-window/README.md+17 −0native/smoke/document-window/Sources/Smoke.swift+76 −0
CI passed — run details
diff --git a/native/apple/Sources/DocsKit/DocsWorkspace.swift b/native/apple/Sources/DocsKit/DocsWorkspace.swift| index 5bf4c69..aa49955 100644 |
| --- a/native/apple/Sources/DocsKit/DocsWorkspace.swift |
| +++ b/native/apple/Sources/DocsKit/DocsWorkspace.swift |
| @@ -158,19 +158,27 @@ import SwiftUI |
| } |
| } |
| .toolbar { Button(docsMacString("Inspector"), systemImage: "sidebar.right") { inspecting.toggle() } } |
| - .inspector(isPresented: $inspecting) { |
| - Form { |
| - LabeledContent(docsMacString("Document"), value: model.detail?.title ?? "") |
| - Picker(docsMacString("Zoom"), selection: $zoom) { |
| - ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 2.0], id: \.self) { value in |
| - Text(value, format: .percent).tag(value) |
| + // A closed SwiftUI inspector can repeatedly invalidate hosting-view |
| + // constraints on macOS 27 while the editor loads. Keep this panel in |
| + // the ordinary view layout so opening a document has stable sizing. |
| + .safeAreaInset(edge: .trailing, spacing: 0) { |
| + if inspecting { |
| + Form { |
| + LabeledContent(docsMacString("Document"), value: model.detail?.title ?? "") |
| + Picker(docsMacString("Zoom"), selection: $zoom) { |
| + ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 2.0], id: \.self) { value in |
| + Text(value, format: .percent).tag(value) |
| + } |
| } |
| - } |
| - Toggle(docsMacString("Pages"), isOn: $paged) |
| - if let projection = model.projection { |
| - DocsToolsControls(projection: projection, content: NativeTextProjection.render(projection), selection: model.selection) |
| - } |
| - }.formStyle(.grouped).inspectorColumnWidth(min: 240, ideal: 280, max: 340) |
| + Toggle(docsMacString("Pages"), isOn: $paged) |
| + if let projection = model.projection { |
| + DocsToolsControls(projection: projection, content: NativeTextProjection.render(projection), selection: model.selection) |
| + } |
| + }.formStyle(.grouped).frame(width: 280) |
| + .frame(maxHeight: .infinity) |
| + .background(.background) |
| + .overlay(alignment: .leading) { Divider() } |
| + } |
| } |
| .focusedSceneValue(\.docsEditor, DocsEditorContext(model: model, client: client, zoom: $zoom, paged: $paged, inspecting: $inspecting, sharing: $sharing, exportRequest: $exportRequest)) |
| #endif |
diff --git a/native/scripts/test-document-window.py b/native/scripts/test-document-window.py| new file mode 100644 |
| index 0000000..c129982 |
| --- /dev/null |
| +++ b/native/scripts/test-document-window.py |
| @@ -0,0 +1,49 @@ |
| +#!/usr/bin/env python3 |
| +"""Exercise the real SwiftUI window on a logged-in Mac using only a local fixture.""" |
| +import http.server |
| +import json |
| +import os |
| +from pathlib import Path |
| +import subprocess |
| +import sys |
| +import threading |
| +import time |
| + |
| +root = Path(__file__).resolve().parents[2] |
| +fixture = (root / "internal/docs/seeddata/letter.update.bin").read_bytes() |
| + |
| +class Handler(http.server.BaseHTTPRequestHandler): |
| + def do_GET(self): |
| + if self.path.endswith("/stream"): |
| + self.send_response(200) |
| + self.send_header("Content-Type", "text/event-stream") |
| + self.end_headers() |
| + self.wfile.write(b"data: 0\n\n") |
| + self.wfile.flush() |
| + time.sleep(20) |
| + return |
| + if "/updates" in self.path: |
| + data = fixture |
| + else: |
| + time.sleep(0.3) # Include loading -> editor layout transition. |
| + data = json.dumps(dict(sqid="fixture", title="Window smoke fixture", guid="window-smoke", |
| + can_write=False, can_share=False)).encode() |
| + self.send_response(200) |
| + self.send_header("Docs-Seq", "0") |
| + self.send_header("Content-Length", str(len(data))) |
| + self.end_headers() |
| + self.wfile.write(data) |
| + |
| + def log_message(self, *_): |
| + pass |
| + |
| +with http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) as server: |
| + threading.Thread(target=server.serve_forever, daemon=True).start() |
| + env = dict(os.environ, DOCS_SMOKE_ORIGIN=f"http://127.0.0.1:{server.server_port}") |
| + result = subprocess.run(["swift", "run", "--package-path", str(root / "native/smoke/document-window")], |
| + env=env, timeout=180, stdout=subprocess.PIPE, text=True) |
| + print(result.stdout, end="") |
| + server.shutdown() |
| + if result.returncode == 0 and "PASS: document loads," not in result.stdout: |
| + sys.exit("Smoke process exited before completing its assertions") |
| + raise SystemExit(result.returncode) |
diff --git a/native/smoke/document-window/Package.resolved b/native/smoke/document-window/Package.resolved| new file mode 100644 |
| index 0000000..97c1f2e |
| --- /dev/null |
| +++ b/native/smoke/document-window/Package.resolved |
| @@ -0,0 +1,23 @@ |
| +{ |
| + "originHash" : "b431b6e11c703561af9cea92a342e39b35786f2616f8b5e3d4ab22ca3a406a4f", |
| + "pins" : [ |
| + { |
| + "identity" : "native", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://amadan.net/rastrillo/native", |
| + "state" : { |
| + "revision" : "229525909e1d6ac3b6a353d1c4d022798b0e9a24" |
| + } |
| + }, |
| + { |
| + "identity" : "sparkle", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/sparkle-project/Sparkle", |
| + "state" : { |
| + "revision" : "ac2def288cbff5cfc7df3ffef6abdf45b72bcb0a", |
| + "version" : "2.9.6" |
| + } |
| + } |
| + ], |
| + "version" : 3 |
| +} |
diff --git a/native/smoke/document-window/Package.swift b/native/smoke/document-window/Package.swift| new file mode 100644 |
| index 0000000..61a51d6 |
| --- /dev/null |
| +++ b/native/smoke/document-window/Package.swift |
| @@ -0,0 +1,7 @@ |
| +// swift-tools-version: 6.2 |
| +import PackageDescription |
| +let package = Package(name: "DocumentWindowSmoke", platforms: [.macOS(.v26)], |
| + dependencies: [.package(path: "../../apple")], |
| + targets: [.executableTarget(name: "DocumentWindowSmoke", dependencies: [ |
| + .product(name: "DocsKit", package: "apple") |
| + ], path: "Sources")]) |
diff --git a/native/smoke/document-window/README.md b/native/smoke/document-window/README.md| new file mode 100644 |
| index 0000000..3687f14 |
| --- /dev/null |
| +++ b/native/smoke/document-window/README.md |
| @@ -0,0 +1,17 @@ |
| +# Document window smoke test |
| + |
| +Run `python3 native/scripts/test-document-window.py` from a logged-in macOS |
| +session. It opens a temporary app window and needs permission to become active. |
| +No account, production service, or user document is used: a loopback HTTP server |
| +serves the repository's synthetic letter seed. |
| + |
| +The test exercises the public DocsWorkspace in a SwiftUI document scene. It waits |
| +for the editor to load, opens/closes the inspector eight times through the native |
| +menu, checks the editor gains/loses panel space, resizes the window, and verifies |
| +that the same text view retains its content and selection. |
| + |
| +Regression: Docs 0.1.1 (3) could crash opening its first document on macOS 27. |
| +Restoring the `.inspector` container reproduces SIGTRAP with AppKit's “more Update |
| +Constraints in Window passes than there are views” exception. The docked settings |
| +panel avoids that container. The negative and fixed scene runs were verified on |
| +macOS 27 build 26A5388g; the user report was build 26A428. |
diff --git a/native/smoke/document-window/Sources/Smoke.swift b/native/smoke/document-window/Sources/Smoke.swift| new file mode 100644 |
| index 0000000..cc7b9b0 |
| --- /dev/null |
| +++ b/native/smoke/document-window/Sources/Smoke.swift |
| @@ -0,0 +1,76 @@ |
| +import AppKit |
| +import DocsKit |
| +import RastrilloApple |
| +import SwiftUI |
| + |
| +@main struct DocumentWindowSmoke: App { |
| + var body: some Scene { |
| + Window("Smoke Home", id: "home") { Launcher() } |
| + WindowGroup("Smoke Document", id: "document", for: String.self) { _ in |
| + DocsWorkspace(client: NativeHTTP(origin: URL(string: ProcessInfo.processInfo.environment["DOCS_SMOKE_ORIGIN"]!)!), |
| + signOut: {}, documentID: "fixture") |
| + }.defaultSize(width: 1120, height: 820) |
| + .restorationBehavior(.disabled) |
| + .commands { DocsEditorCommands() } |
| + } |
| +} |
| + |
| +private struct Launcher: View { |
| + @Environment(\.openWindow) private var openWindow |
| + var body: some View { |
| + Text("Synthetic document window smoke test").frame(width: 400, height: 200) |
| + .task { |
| + NSApp.setActivationPolicy(.regular) |
| + openWindow(id: "document", value: "fixture") |
| + for _ in 0..<100 { |
| + if let window = NSApp.windows.first(where: { $0.title == "Window smoke fixture" }), |
| + let editor = window.contentView.flatMap(findEditor), !editor.string.isEmpty { |
| + await exercise(window, editor: editor) |
| + return |
| + } |
| + try? await Task.sleep(for: .milliseconds(100)) |
| + } |
| + fatalError("Document editor did not load") |
| + } |
| + } |
| + @MainActor private func findEditor(_ view: NSView) -> NSTextView? { |
| + if let text = view as? NSTextView, text.accessibilityIdentifier() == "docs.editor" { return text } |
| + return view.subviews.lazy.compactMap(findEditor).first |
| + } |
| + @MainActor private func menuItem(_ title: String, in menu: NSMenu) -> NSMenuItem? { |
| + for item in menu.items { |
| + if item.title == title { return item } |
| + if let submenu = item.submenu, let match = menuItem(title, in: submenu) { return match } |
| + } |
| + return nil |
| + } |
| + @MainActor private func exercise(_ window: NSWindow, editor: NSTextView) async { |
| + NSApp.activate(ignoringOtherApps: true) |
| + window.makeKeyAndOrderFront(nil) |
| + window.makeFirstResponder(editor) |
| + editor.setSelectedRange(NSRange(location: 1, length: 2)) |
| + let original = editor.string |
| + for step in 0..<8 { |
| + try? await Task.sleep(for: .milliseconds(200)) |
| + guard let menu = NSApp.mainMenu, let initial = menuItem(docsMacString("Document inspector"), in: menu), let submenu = initial.menu else { |
| + fatalError("Document inspector command unavailable") |
| + } |
| + submenu.delegate?.menuNeedsUpdate?(submenu) |
| + submenu.update() |
| + guard let item = menuItem(docsMacString("Document inspector"), in: menu), item.isEnabled else { |
| + fatalError("Inspector command disabled in document window") |
| + } |
| + let beforeWidth = editor.enclosingScrollView!.frame.width |
| + item.menu?.performActionForItem(at: item.menu!.index(of: item)) |
| + try? await Task.sleep(for: .milliseconds(200)) |
| + precondition(abs(editor.enclosingScrollView!.frame.width - beforeWidth) > 200, "Inspector did not change editor space") |
| + window.setContentSize(NSSize(width: step.isMultiple(of: 2) ? 900 : 1200, height: 760)) |
| + try? await Task.sleep(for: .milliseconds(200)) |
| + window.contentView?.layoutSubtreeIfNeeded() |
| + precondition(window.contentView.flatMap(findEditor) === editor, "Inspector recreated editor") |
| + precondition(editor.string == original && editor.selectedRange() == NSRange(location: 1, length: 2), "Panel lost content or selection") |
| + } |
| + print("PASS: document loads, inspector toggles, window resizes, editor and selection persist") |
| + NSApp.terminate(nil) |
| + } |
| +} |