oficina / docs Public

Fix document opening constraint crash with docked inspector panel

Paul Campbell pushed by paul@keymail.dev e2919ffa173a76e76913a47d2c4ce4efc6f4cb63 parent bed1bac
6 files changed, +192 −12
  • native/apple/Sources/DocsKit/DocsWorkspace.swift +20 −12
  • native/scripts/test-document-window.py +49 −0
  • native/smoke/document-window/Package.resolved +23 −0
  • native/smoke/document-window/Package.swift +7 −0
  • native/smoke/document-window/README.md +17 −0
  • native/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)
+ }
+}