Skip to content
27 changes: 27 additions & 0 deletions Sources/PreviewAgent/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,33 @@ int main(int argc, char *argv[]) {
dlsym(RTLD_DEFAULT, "NSApplicationLoad")))
Load();

// Run the full AppKit event loop, not a bare CFRunLoop spin: windows hosted
// in this process only receive window-server events (clicks, scrolls) when
// -[NSApplication run] dequeues and dispatches them. The main dispatch
// queue (run_on_main's dispatch_sync target) drains under it as well.
// Guarded on an Aqua session being reachable: registering with the window
// server from an SSH login or launchd context can kill the process, and
// off-screen rendering works under the bare spin there.
auto *SessionDict = reinterpret_cast<void *(*)()>(
dlsym(RTLD_DEFAULT, "CGSessionCopyCurrentDictionary"));
if (SessionDict && SessionDict()) {
auto *GetClass = reinterpret_cast<void *(*)(const char *)>(
dlsym(RTLD_DEFAULT, "objc_getClass"));
auto *RegisterSel = reinterpret_cast<void *(*)(const char *)>(
dlsym(RTLD_DEFAULT, "sel_registerName"));
auto *MsgSend = reinterpret_cast<void *(*)(void *, void *)>(
dlsym(RTLD_DEFAULT, "objc_msgSend"));
if (GetClass && RegisterSel && MsgSend) {
if (void *AppClass = GetClass("NSApplication")) {
void *App = MsgSend(AppClass, RegisterSel("sharedApplication"));
if (App)
MsgSend(App, RegisterSel("run")); // never returns
}
}
}

// Fallback when AppKit or the window server is unavailable: keep servicing
// the main queue.
auto *RunInMode =
reinterpret_cast<int (*)(const void *, double, unsigned char)>(
dlsym(RTLD_DEFAULT, "CFRunLoopRunInMode"));
Expand Down
3 changes: 2 additions & 1 deletion Sources/PreviewsCLI/Handlers/PreviewStartHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,8 @@ private func startMacOSPreview(
setupModule: setupResult?.moduleName,
setupType: setupResult?.typeName,
setupCompilerFlags: setupResult?.compilerFlags ?? [],
setupSDKPath: setupResult?.sdkPath
setupSDKPath: setupResult?.sdkPath,
setupDylibPath: setupResult?.dylibPath
)

let compileResult = try await session.compile()
Expand Down
115 changes: 95 additions & 20 deletions Sources/PreviewsCore/BridgeGenerator.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
import Foundation

/// On-screen placement for the agent's persistent preview window, baked into the render
/// entry. Applied only when the agent creates the window, so a user's later drag or resize
/// survives leaf edits. Nil means the window stays borderless and off-screen (tests,
/// headless daemons).
public struct JITRenderWindow: Sendable {
public let x: Double
public let y: Double
public let width: Double
public let height: Double
public let title: String

public init(x: Double, y: Double, width: Double, height: Double, title: String) {
self.x = x
self.y = y
self.width = width
self.height = height
self.title = title
}
}

/// Generates Swift source code that combines the original source file with a `@_cdecl` bridge
/// entry point, allowing the preview view to be loaded via `dlopen` + `dlsym`.
public enum BridgeGenerator {
Expand All @@ -24,7 +44,8 @@ public enum BridgeGenerator {
setupType: String? = nil,
renderOutputPath: String? = nil,
designTimeValuesPath: String? = nil,
stableModuleImport: String? = nil
stableModuleImport: String? = nil,
renderWindow: JITRenderWindow? = nil
) -> (source: String, literals: [LiteralEntry]) {
// Transform source to replace literals with DesignTimeStore lookups
let thunkResult = ThunkGenerator.transform(source: originalSource)
Expand All @@ -39,9 +60,7 @@ public enum BridgeGenerator {
}

let modifiers = traitModifiers(traits)
let hasSetup =
setupModule != nil && setupType != nil
&& isValidSwiftIdentifier(setupModule!) && isValidSwiftIdentifier(setupType!)
let hasSetup = isUsableSetup(module: setupModule, type: setupType)
let setupImport = hasSetup ? "import \(setupModule!)\n" : ""
let setUpEntry = hasSetup ? setUpEntryPoint(setupType: setupType!) : ""
let viewCode =
Expand All @@ -59,7 +78,8 @@ public enum BridgeGenerator {
let renderEntry =
renderOutputPath.map {
renderToFileEntryPoint(
viewCode: viewCode, path: $0, valuesPath: designTimeValuesPath)
viewCode: viewCode, path: $0, valuesPath: designTimeValuesPath,
window: renderWindow)
} ?? ""
let bridgeCode: String
switch platform {
Expand Down Expand Up @@ -147,9 +167,7 @@ public enum BridgeGenerator {
}

let modifiers = traitModifiers(traits)
let hasSetup =
setupModule != nil && setupType != nil
&& isValidSwiftIdentifier(setupModule!) && isValidSwiftIdentifier(setupType!)
let hasSetup = isUsableSetup(module: setupModule, type: setupType)
let setupImport = hasSetup ? "\nimport \(setupModule!)" : ""
let setUpEntry = hasSetup ? "\n\n\(setUpEntryPoint(setupType: setupType!))\n" : ""
let viewCode =
Expand Down Expand Up @@ -242,6 +260,32 @@ public enum BridgeGenerator {
return s.range(of: pattern, options: .regularExpression) != nil
}

/// Whether a setup module/type pair will actually produce setup code (the wrap and the
/// `previewSetUp` entry). Callers that advertise setup downstream (e.g. a build's
/// setupEntrySymbol) must use the same predicate, or they promise an entry the generated
/// source does not contain.
public static func isUsableSetup(module: String?, type: String?) -> Bool {
guard let module, let type else { return false }
return isValidSwiftIdentifier(module) && isValidSwiftIdentifier(type)
}

/// Escape a runtime string (path, window title) for interpolation into a generated Swift
/// string literal. Backslash and quote would change the literal's structure; control
/// characters (a newline in a file name is legal on macOS) would split it across lines.
static func escapedForSwiftStringLiteral(_ s: String) -> String {
var out = ""
for scalar in s.unicodeScalars {
switch scalar {
case "\\": out += "\\\\"
case "\"": out += "\\\""
case let c where c.properties.generalCategory == .control:
out += "\\u{\(String(c.value, radix: 16))}"
default: out.unicodeScalars.append(scalar)
}
}
return out
}

/// Generate the `@_cdecl("previewBodyKind")` entry point. The compiler picks the same
/// `__PreviewBodyKindProbe.detect` overload that `__PreviewBridge.wrap` does, so the
/// returned value reflects the outermost body kind: 1 = SwiftUI, 2 = UIView,
Expand All @@ -265,41 +309,72 @@ public enum BridgeGenerator {
/// blank raster for them), captures at a deterministic 1x like `Snapshot.capture`,
/// and writes a PNG to `path`. Nullary so it runs over the agent's `runOnMain`
/// surface; the path is baked in (the daemon recompiles per structural edit).
///
/// The window is process-persistent: each generation's entry looks it up by
/// identifier in the agent's window list and swaps its content view, so the agent
/// keeps one live window across structural edits (single-renderer consolidation).
/// AppKit state is shared across JITDylib generations; the entry's own globals are not.
private static func renderToFileEntryPoint(
viewCode: String, path: String, valuesPath: String?
viewCode: String, path: String, valuesPath: String?, window: JITRenderWindow?
) -> String {
let seed =
valuesPath.map {
"""
if let __dtData = try? Data(contentsOf: URL(fileURLWithPath: "\($0)")),
if let __dtData = try? Data(contentsOf: URL(fileURLWithPath: "\(escapedForSwiftStringLiteral($0))")),
let __dtValues = try? JSONSerialization.jsonObject(with: __dtData)
as? [String: Any]
{
DesignTimeStore.shared.values = __dtValues
}
"""
} ?? ""
let createWindow: String
if let window {
let title = escapedForSwiftStringLiteral(window.title)
createWindow = """
NSApplication.shared.setActivationPolicy(.accessory)
let created = NSWindow(
contentRect: NSRect(
x: \(window.x), y: \(window.y),
width: \(window.width), height: \(window.height)),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered, defer: false)
created.title = "\(title)"
"""
} else {
createWindow = """
let created = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 400, height: 600),
styleMask: [.borderless], backing: .buffered, defer: false)
created.setFrameOrigin(NSPoint(x: -10_000, y: -10_000))
"""
}
return """
@_cdecl("renderPreviewToFile")
public func renderPreviewToFile() -> Int32 {
MainActor.assumeIsolated {
\(seed)
let view = \(viewCode)
let bounds = NSRect(x: 0, y: 0, width: 400, height: 600)
let window = NSWindow(
contentRect: bounds, styleMask: [.borderless], backing: .buffered,
defer: false)
let identifier = NSUserInterfaceItemIdentifier("previewsmcp-preview")
let window =
NSApplication.shared.windows.first { $0.identifier == identifier }
?? {
\(createWindow)
created.identifier = identifier
created.isReleasedWhenClosed = false
return created
}()
let hosting = NSHostingView(rootView: view)
hosting.sizingOptions = []
window.contentView = hosting
window.setFrameOrigin(NSPoint(x: -10_000, y: -10_000))
window.orderFrontRegardless()
hosting.layoutSubtreeIfNeeded()
defer { window.orderOut(nil) }
guard
let bounds = hosting.bounds
guard bounds.width > 0, bounds.height > 0,
let rep = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: Int(bounds.width),
pixelsHigh: Int(bounds.height),
pixelsWide: Int(bounds.width.rounded()),
pixelsHigh: Int(bounds.height.rounded()),
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
Expand All @@ -315,7 +390,7 @@ public enum BridgeGenerator {
return Int32(-2)
}
do {
try data.write(to: URL(fileURLWithPath: "\(path)"))
try data.write(to: URL(fileURLWithPath: "\(escapedForSwiftStringLiteral(path))"))
} catch {
return Int32(-3)
}
Expand Down
77 changes: 42 additions & 35 deletions Sources/PreviewsCore/Compiler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,31 +89,7 @@ public actor Compiler {
let uniqueName = "\(moduleName)_\(compilationCounter)"
let sourceFile = workDir.appendingPathComponent("\(uniqueName).swift")
let dylibFile = workDir.appendingPathComponent("\(uniqueName).dylib")
let effectiveSDK = overrideSDK ?? sdkPath

// Layer 3 guard for issue #170: if the caller passes an SDK that no
// longer exists (e.g. user upgraded Xcode after a SetupBuilder build
// landed in cache, or hand-supplied a bogus path), fail fast with an
// actionable error before swiftc surfaces a generic "cannot find SDK"
// diagnostic that doesn't hint at the cache-staleness root cause.
if let overrideSDK, !FileManager.default.fileExists(atPath: overrideSDK) {
throw CompilationError(
message:
"Setup module was built against SDK at \(overrideSDK), which "
+ "no longer exists on disk. The active toolchain resolves to "
+ "\(sdkPath). Delete the setup cache (.build/previewsmcp-setup-cache) "
+ "or rebuild the setup package to capture the current SDK.",
stderr: "",
exitCode: 1
)
}

if let overrideSDK, overrideSDK != sdkPath {
Log.warn(
"compileCombined: setup SDK differs from active toolchain SDK "
+ "(setup=\(overrideSDK), default=\(sdkPath)). Inheriting setup "
+ "SDK to keep swiftmodule load consistent.")
}
let effectiveSDK = try resolveSDK(overrideSDK)

Log.info(
"compileCombined: module=\(moduleName) platform=\(platform) "
Expand Down Expand Up @@ -151,7 +127,8 @@ public actor Compiler {
public func compileObject(
source: String,
moduleName: String,
extraFlags: [String] = []
extraFlags: [String] = [],
overrideSDK: String? = nil
) async throws -> URL {
compilationCounter += 1
let uniqueName = "\(moduleName)_\(compilationCounter)"
Expand All @@ -165,7 +142,7 @@ public actor Compiler {
"-emit-object",
"-parse-as-library",
"-target", targetTriple,
"-sdk", sdkPath,
"-sdk", try resolveSDK(overrideSDK),
"-module-name", moduleName,
"-Onone",
"-gnone",
Expand Down Expand Up @@ -193,7 +170,8 @@ public actor Compiler {
public func emitStableModule(
sources: [String],
moduleName: String,
extraFlags: [String] = []
extraFlags: [String] = [],
overrideSDK: String? = nil
) async throws -> StableModule {
compilationCounter += 1
let moduleDir = workDir.appendingPathComponent(
Expand All @@ -209,30 +187,32 @@ public actor Compiler {

return try await emitStableModule(
sourceFiles: sourceFiles, moduleName: moduleName, moduleDir: moduleDir,
extraFlags: extraFlags)
extraFlags: extraFlags, overrideSDK: overrideSDK)
}

/// File-based variant: compile existing project sources in place (their real paths) into
/// the stable module, without copying. Used by the Tier-2 recompile-narrowing split.
public func emitStableModule(
sourceFiles: [URL],
moduleName: String,
extraFlags: [String] = []
extraFlags: [String] = [],
overrideSDK: String? = nil
) async throws -> StableModule {
compilationCounter += 1
let moduleDir = workDir.appendingPathComponent(
"stable-\(moduleName)-\(compilationCounter)", isDirectory: true)
try FileManager.default.createDirectory(at: moduleDir, withIntermediateDirectories: true)
return try await emitStableModule(
sourceFiles: sourceFiles, moduleName: moduleName, moduleDir: moduleDir,
extraFlags: extraFlags)
extraFlags: extraFlags, overrideSDK: overrideSDK)
}

private func emitStableModule(
sourceFiles: [URL],
moduleName: String,
moduleDir: URL,
extraFlags: [String]
extraFlags: [String],
overrideSDK: String?
) async throws -> StableModule {
let objectFile = moduleDir.appendingPathComponent("\(moduleName).o")
let moduleFile = moduleDir.appendingPathComponent("\(moduleName).swiftmodule")
Expand All @@ -244,7 +224,7 @@ public actor Compiler {
"-parse-as-library",
"-enable-testing",
"-target", targetTriple,
"-sdk", sdkPath,
"-sdk", try resolveSDK(overrideSDK),
"-module-name", moduleName,
"-Onone",
"-gnone",
Expand Down Expand Up @@ -274,7 +254,8 @@ public actor Compiler {
overlaySource: String,
bulkFiles: [URL],
moduleName: String,
extraFlags: [String] = []
extraFlags: [String] = [],
overrideSDK: String? = nil
) async throws -> (overlayObject: URL, bulkObjects: [URL]) {
let dir: URL
if let existing = incrementalDirs[moduleName] {
Expand Down Expand Up @@ -317,7 +298,7 @@ public actor Compiler {
"-emit-object",
"-parse-as-library",
"-target", targetTriple,
"-sdk", sdkPath,
"-sdk", try resolveSDK(overrideSDK),
"-module-name", moduleName,
"-Onone",
"-gnone",
Expand All @@ -334,6 +315,32 @@ public actor Compiler {

// MARK: - Private

/// Resolve the SDK for a compile, honoring a setup-module override. Layer 3 guard for
/// issue #170: if the override SDK no longer exists (e.g. user upgraded Xcode after a
/// SetupBuilder build landed in cache), fail fast with an actionable error before swiftc
/// surfaces a generic "cannot find SDK" diagnostic that doesn't hint at cache staleness.
private func resolveSDK(_ overrideSDK: String?) throws -> String {
guard let overrideSDK else { return sdkPath }
guard FileManager.default.fileExists(atPath: overrideSDK) else {
throw CompilationError(
message:
"Setup module was built against SDK at \(overrideSDK), which "
+ "no longer exists on disk. The active toolchain resolves to "
+ "\(sdkPath). Delete the setup cache (.build/previewsmcp-setup-cache) "
+ "or rebuild the setup package to capture the current SDK.",
stderr: "",
exitCode: 1
)
}
if overrideSDK != sdkPath {
Log.warn(
"compile: setup SDK differs from active toolchain SDK "
+ "(setup=\(overrideSDK), default=\(sdkPath)). Inheriting setup "
+ "SDK to keep swiftmodule load consistent.")
}
return overrideSDK
}

@discardableResult
private func run(_ args: [String]) async throws -> String {
let output = try await runAsync(args[0], arguments: Array(args.dropFirst()))
Expand Down
Loading