diff --git a/Sources/SwiftGtkGenCore/CodeGen+Scaffolding.swift b/Sources/SwiftGtkGenCore/CodeGen+Scaffolding.swift
index b57e785..4c392fc 100644
--- a/Sources/SwiftGtkGenCore/CodeGen+Scaffolding.swift
+++ b/Sources/SwiftGtkGenCore/CodeGen+Scaffolding.swift
@@ -136,9 +136,18 @@ extension CodeGenerator {
let cName = "C\(moduleName)"
let lowerModName = moduleName.lowercased()
let cHeader = repo.cHeaderPath
- let umbrellaHeader = cHeader.isEmpty
+ var umbrellaHeader = cHeader.isEmpty
? "#include <\(lowerModName)/\(lowerModName).h>"
: "#include <\(cHeader)>"
+ // GTK4's own `` does not cover the
+ // `gtk/gtkunixprint.h` header that must be included
+ // explicitly, even though the GIR declares them as ordinary Gtk
+ // namespace members. Root-cause fix, not a skip: the C symbols
+ // are real and exported by libgtk-4.so; only the umbrella header
+ // GTK's own GIR points at omits them.
+ if moduleName == "Gtk" {
+ umbrellaHeader += "\n#include "
+ }
// No `link` directives: the `.systemLibrary` target carries
// `pkgConfig:`, so pkg-config `--libs` supplies the exact linker
@@ -184,7 +193,11 @@ extension CodeGenerator {
var cTargets = ""
for name in moduleNames {
let cName = "C\(name)"
- let pkgConfigName = analysis.repositories[name]?.packageName ?? ""
+ // Gtk needs `gtk4-unix-print`'s cflags (a strict superset of `gtk4`'s,
+ // adding only the `-I.../unix-print` search path the umbrella
+ // header above pulls `gtk/gtkunixprint.h` from) — same libs, same .so.
+ var pkgConfigName = analysis.repositories[name]?.packageName ?? ""
+ if name == "Gtk" && pkgConfigName == "gtk4" { pkgConfigName = "gtk4-unix-print" }
let pkgConfigArg = pkgConfigName.isEmpty
? ""
: ", pkgConfig: \"\(pkgConfigName)\", providers: [.apt([\"\(pkgConfigName)\"]), .brew([\"\(pkgConfigName)\"])]"
diff --git a/Sources/SwiftGtkGenCore/CodeGen.swift b/Sources/SwiftGtkGenCore/CodeGen.swift
index b2c51e3..e1ace47 100644
--- a/Sources/SwiftGtkGenCore/CodeGen.swift
+++ b/Sources/SwiftGtkGenCore/CodeGen.swift
@@ -17,7 +17,7 @@ public struct CodeGenerator {
guard !transitive.isEmpty else { continue }
var content = header
for dep in transitive.sorted() {
- content += "@_exported import \(dep)\n"
+ content += "@_spi(SGTKInternal) @_exported import \(dep)\n"
}
content += "\n"
files["Sources/\(moduleName)/\(moduleName).swift"] = content
diff --git a/Sources/SwiftGtkGenCore/TypeMapper.swift b/Sources/SwiftGtkGenCore/TypeMapper.swift
index 0ea5a9d..7c40640 100644
--- a/Sources/SwiftGtkGenCore/TypeMapper.swift
+++ b/Sources/SwiftGtkGenCore/TypeMapper.swift
@@ -330,7 +330,7 @@ private func mapTypeRef(
swiftType: swiftType, cSwiftType: "UnsafeMutableRawPointer?",
marshalIn: .boxedPointer, marshalOut: .boxedWrap(copy: transfer != .full, copyFunction: copyFn),
gvalue: GValueOps(typeMacro: "G_TYPE_BOXED",
- getterSuffix: "boxed", setterSuffix: "boxed"),
+ getterSuffix: "boxed", setterSuffix: "boxed", hasCopyFunction: copyFn != nil),
category: .needsRecord)
case .gtypeStruct:
diff --git a/Tests/SwiftGtkGenCoreTests/CallbackGenerationTests.swift b/Tests/SwiftGtkGenCoreTests/CallbackGenerationTests.swift
index 239cfb2..8d2f5c2 100644
--- a/Tests/SwiftGtkGenCoreTests/CallbackGenerationTests.swift
+++ b/Tests/SwiftGtkGenCoreTests/CallbackGenerationTests.swift
@@ -42,8 +42,26 @@ struct CallbackGenerationTests {
coverage: CoverageStats())
let files = renderModule(module)
let source = files["Callbacks.swift"] ?? ""
- #expect(source.contains("public typealias CompareFunc = @convention(c) (UnsafeRawPointer?, UnsafeRawPointer?) -> Int32"))
- #expect(source.contains("public typealias CompareFuncSwift = (UnsafeRawPointer?, UnsafeRawPointer?) -> Int32"))
+ #expect(source.contains("@_spi(SGTKInternal) public typealias CompareFunc = @convention(c) (UnsafeRawPointer?, UnsafeRawPointer?) -> Int32"))
+ // The Swift-closure form still carries raw UnsafeRawPointer args here,
+ // so it is hidden behind @_spi too (Phase E4 gpointer policy).
+ #expect(source.contains("@_spi(SGTKInternal) public typealias CompareFuncSwift = (UnsafeRawPointer?, UnsafeRawPointer?) -> Int32"))
+ }
+
+ @Test("A pointer-free Swift-closure form stays plain public; only the @convention(c) form is @_spi")
+ func callbackTypePointerFreeSwiftFormStaysPublic() throws {
+ let plan = CallbackTypePlan(
+ name: "NotifyFunc",
+ swiftType: "(String) -> Void",
+ cSwiftType: "@convention(c) (UnsafeMutableRawPointer?) -> Void"
+ )
+ let module = ModulePlan(module: "GLib", types: [.callback(plan)], skips: [],
+ coverage: CoverageStats())
+ let files = renderModule(module)
+ let source = files["Callbacks.swift"] ?? ""
+ #expect(source.contains("@_spi(SGTKInternal) public typealias NotifyFunc = @convention(c) (UnsafeMutableRawPointer?) -> Void"))
+ #expect(source.contains("public typealias NotifyFuncSwift = (String) -> Void"))
+ #expect(!source.contains("@_spi(SGTKInternal) public typealias NotifyFuncSwift"))
}
// MARK: - Planner baseline (unchanged pending D4.3)
diff --git a/Tests/SwiftGtkGenCoreTests/InterfaceConformanceTests.swift b/Tests/SwiftGtkGenCoreTests/InterfaceConformanceTests.swift
index d26b895..40aea6e 100644
--- a/Tests/SwiftGtkGenCoreTests/InterfaceConformanceTests.swift
+++ b/Tests/SwiftGtkGenCoreTests/InterfaceConformanceTests.swift
@@ -80,7 +80,7 @@ struct InterfaceConformanceTests {
// Protocol body is bare — only the `pointer` requirement.
#expect(ifaceSrc.contains("public protocol TypePlugin {"))
- #expect(ifaceSrc.contains("var pointer: UnsafeMutableRawPointer { get }"))
+ #expect(ifaceSrc.contains("@_spi(SGTKInternal) var pointer: UnsafeMutableRawPointer { get }"))
// The interface method is a protocol EXTENSION default, not a
// requirement — it keeps its own (Void) signature regardless of the
@@ -89,9 +89,10 @@ struct InterfaceConformanceTests {
#expect(ifaceSrc.contains("public func use("))
// Concrete Ref wrapper is emitted so `any TypePlugin` is constructible.
- #expect(ifaceSrc.contains("public final class TypePluginRef: TypePlugin {"))
- #expect(ifaceSrc.contains("init(retaining pointer: UnsafeMutableRawPointer)"))
- #expect(ifaceSrc.contains("init(takingOwnership pointer: UnsafeMutableRawPointer)"))
+ #expect(ifaceSrc.contains("public final class TypePluginRef: @MainActor TypePlugin {"))
+ #expect(ifaceSrc.contains("@_spi(SGTKInternal) public let pointer: UnsafeMutableRawPointer"))
+ #expect(ifaceSrc.contains("@_spi(SGTKInternal) public init(retaining pointer: UnsafeMutableRawPointer)"))
+ #expect(ifaceSrc.contains("@_spi(SGTKInternal) public init(takingOwnership pointer: UnsafeMutableRawPointer)"))
#expect(ifaceSrc.contains("isolated deinit"))
}
diff --git a/Tests/SwiftGtkGenCoreTests/PropertyGenerationTests.swift b/Tests/SwiftGtkGenCoreTests/PropertyGenerationTests.swift
index 24ae06e..fc9ffd3 100644
--- a/Tests/SwiftGtkGenCoreTests/PropertyGenerationTests.swift
+++ b/Tests/SwiftGtkGenCoreTests/PropertyGenerationTests.swift
@@ -115,6 +115,20 @@ struct PropertyGenerationTests {
#expect(!source.contains("g_object_set_property"))
}
+ // MARK: - Phase E4: no-unsafe-pointer public API policy
+
+ @Test("A root class's pointer storage and both instance inits are @_spi(SGTKInternal), the class itself stays public")
+ func classPointerSurfaceIsSPIGated() throws {
+ let prop = Property(name: "length", type: .int32, isReadable: true, isWritable: false)
+ let (source, _, _) = renderClass(named: "Buffer", properties: [prop])
+ #expect(source.contains("public class Buffer {"))
+ #expect(source.contains("@_spi(SGTKInternal) public let pointer: UnsafeMutableRawPointer"))
+ #expect(source.contains("@_spi(SGTKInternal) public required init(takingOwnership pointer: UnsafeMutableRawPointer)"))
+ #expect(source.contains("@_spi(SGTKInternal) public init(retaining pointer: UnsafeMutableRawPointer)"))
+ // The plain-public getter is unaffected — Int32 carries no raw pointer.
+ #expect(source.contains(" public var length: Int32"))
+ }
+
@Test("Writable string property generates both a getter and a setter")
func writableStringProperty() throws {
let prop = Property(name: "label", type: .string, isReadable: true, isWritable: true)
diff --git a/Tests/SwiftGtkGenCoreTests/RecordGenerationTests.swift b/Tests/SwiftGtkGenCoreTests/RecordGenerationTests.swift
index 6624bcd..f762358 100644
--- a/Tests/SwiftGtkGenCoreTests/RecordGenerationTests.swift
+++ b/Tests/SwiftGtkGenCoreTests/RecordGenerationTests.swift
@@ -77,7 +77,9 @@ struct RecordGenerationTests {
copyFunction: "g_variant_type_copy", freeFunction: "g_variant_type_free"
)
let source = render(plan)
- #expect(source.contains("public init(retaining pointer: UnsafeMutableRawPointer)"))
+ #expect(source.contains("@_spi(SGTKInternal) public let pointer: UnsafeMutableRawPointer"))
+ #expect(source.contains("@_spi(SGTKInternal) public init(takingOwnership pointer: UnsafeMutableRawPointer)"))
+ #expect(source.contains("@_spi(SGTKInternal) public init(retaining pointer: UnsafeMutableRawPointer)"))
#expect(source.contains("g_variant_type_copy(_instancePointer(pointer))"))
#expect(source.contains("isolated deinit {"))
#expect(source.contains("g_variant_type_free(_instancePointer(pointer))"))
diff --git a/Tests/SwiftGtkGenCoreTests/RendererCallableTests.swift b/Tests/SwiftGtkGenCoreTests/RendererCallableTests.swift
index d120dfd..58fb808 100644
--- a/Tests/SwiftGtkGenCoreTests/RendererCallableTests.swift
+++ b/Tests/SwiftGtkGenCoreTests/RendererCallableTests.swift
@@ -57,6 +57,37 @@ struct RendererCallableTests {
#expect(body.contains("result!") || body.contains("marshalReturn"))
}
+ // MARK: - Phase E4: no-unsafe-pointer public API policy
+
+ @Test("A function with a gpointer parameter renders @_spi(SGTKInternal) public func, hiding the pointer")
+ func gpointerParameterHidesFunctionBehindSPI() throws {
+ let fn = GlobalFunction(
+ name: "set_user_data", cIdentifier: "g_set_user_data",
+ parameters: [Parameter(name: "data", type: .pointer, cType: "gpointer")],
+ returnValue: ReturnValue(type: .void)
+ )
+ guard case .success(let plan) = planFunction(fn, context: makeContext()) else {
+ Issue.record("expected set_user_data to plan successfully"); return
+ }
+ let body = renderCallable(plan)
+ #expect(body.contains("@_spi(SGTKInternal) public func setUserData"))
+ }
+
+ @Test("A function returning a plain wrapper type (no raw pointer in its signature) stays plain public")
+ func wrapperReturnStaysPlainPublic() throws {
+ let fn = GlobalFunction(
+ name: "get_default_object", cIdentifier: "g_get_default_object",
+ parameters: [],
+ returnValue: ReturnValue(type: .typeRef("Object", namespace: "GObject"))
+ )
+ guard case .success(let plan) = planFunction(fn, context: makeContext()) else {
+ Issue.record("expected get_default_object to plan successfully"); return
+ }
+ let body = renderCallable(plan)
+ #expect(body.contains("public func getDefaultObject"))
+ #expect(!body.contains("@_spi(SGTKInternal) public func getDefaultObject"))
+ }
+
// MARK: - C3: Out-param tuples
@Test("Single out-param with no Swift return becomes the out-param's type")
diff --git a/Tests/SwiftGtkGenCoreTests/SignalGenerationTests.swift b/Tests/SwiftGtkGenCoreTests/SignalGenerationTests.swift
index 9f3b2a3..604af3a 100644
--- a/Tests/SwiftGtkGenCoreTests/SignalGenerationTests.swift
+++ b/Tests/SwiftGtkGenCoreTests/SignalGenerationTests.swift
@@ -130,6 +130,40 @@ struct SignalGenerationTests {
#expect(source.contains("_sgtk_signal_connect_data("))
#expect(!source.contains("_sgtk_signal_connect_data(ptr, cName, unsafeBitCast(\\(plan.trampolineCName)"))
}
+
+ @Test("Support.swift hides GLibError.init(consuming:), SignalHandle.instance/init, and _sgtk_* helpers behind @_spi(SGTKInternal)")
+ func supportPointerSurfaceIsSPIGated() throws {
+ let notify = Signal(name: "notify", isDetailed: true)
+ let klass = Class(name: "Object", cType: "GObject", parent: nil,
+ getTypeFunction: "g_object_get_type", signals: [notify])
+ let (plan, skips) = planClass(klass, context: makeContext())
+ #expect(skips.isEmpty)
+ let module = ModulePlan(module: "GObject", dependencyModules: ["GLib"],
+ types: [.class(plan)], skips: [], coverage: CoverageStats())
+ let support = renderModule(module)["Support.swift"] ?? ""
+ #expect(support.contains("@_spi(SGTKInternal) public let instance: UnsafeMutableRawPointer"))
+ #expect(support.contains("@_spi(SGTKInternal) public init(id: UInt, instance: UnsafeMutableRawPointer)"))
+ #expect(support.contains("@_spi(SGTKInternal) public nonisolated func _sgtk_destroy_notify_impl("))
+ #expect(support.contains("@_spi(SGTKInternal) public nonisolated func _sgtk_signal_connect_data("))
+ #expect(support.contains("@_spi(SGTKInternal) public nonisolated func _sgtk_signal_handler_disconnect("))
+ // SignalHandle itself and its id/disconnect() stay plain public — only
+ // the raw-pointer members are hidden.
+ #expect(support.contains("public struct SignalHandle {"))
+ #expect(support.contains("public let id: UInt"))
+ #expect(support.contains("public mutating func disconnect()"))
+ // Dependency import chokepoint is SPI too.
+ #expect(support.contains("@_spi(SGTKInternal) import GLib"))
+ }
+
+ @Test("Support.swift hides GLibError.init(consuming:) behind @_spi(SGTKInternal), struct/fields stay plain public")
+ func glibErrorConsumingInitIsSPIGated() throws {
+ let module = ModulePlan(module: "GLib", types: [], skips: [], coverage: CoverageStats())
+ let support = renderModule(module)["Support.swift"] ?? ""
+ #expect(support.contains("public struct GLibError: Swift.Error {"))
+ #expect(support.contains("public let domain: UInt32"))
+ #expect(support.contains("@_spi(SGTKInternal) public init(consuming error: UnsafeMutablePointer)"))
+ }
+
@Test("Boxed signal param wraps via retaining: when a copy function exists, takingOwnership: when it doesn't")
func boxedSignalParamWrapperSelection() throws {
let withCopy = Signal(
diff --git a/smoke/SmokeTests.swift b/smoke/SmokeTests.swift
index b49876b..ec9f769 100644
--- a/smoke/SmokeTests.swift
+++ b/smoke/SmokeTests.swift
@@ -13,7 +13,7 @@
import Testing
import GLib
-import GObject
+@_spi(SGTKInternal) import GObject
@Suite("Runtime smoke tests")
struct SmokeTests {
diff --git a/smoke/tier5/GtkSmoke.swift b/smoke/tier5/GtkSmoke.swift
new file mode 100644
index 0000000..f1bdda1
--- /dev/null
+++ b/smoke/tier5/GtkSmoke.swift
@@ -0,0 +1,55 @@
+// GtkSmoke.swift
+// Tier-5-only runtime smoke test for Gtk (Phase E4). Proves — against the
+// REAL libgtk-4, linked at runtime — that the generated bindings reach real
+// GTK C symbols and that the no-unsafe-pointer public API policy still
+// produces a fully usable, pointer-free surface.
+//
+// Both cases are deliberately display-free (no `gtk_init`, no
+// `realize()`/`present()`, no display/backend connection):
+// 1. `getMajorVersion()`/`getMinorVersion()` are plain free-function calls
+// into `gtk_get_major_version`/`gtk_get_minor_version` — guaranteed
+// display-free, proves Gtk links and the friendly public API is
+// callable.
+// 2. `Adjustment(value:lower:upper:stepIncrement:pageIncrement:pageSize:)`
+// reaches the real `gtk_adjustment_new` C constructor and adopts the
+// returned pointer through `init(takingOwnership:)`. `GtkAdjustment` is
+// a plain GObject (not a widget — no realize/display dependency) with a
+// value round-trip through `getValue()`/`setValue(value:)`. Every call
+// in this test uses ONLY plain-public API — no `@_spi` import — which
+// is itself part of the proof that the pointer-free surface is complete
+// enough for real, non-toy usage.
+//
+// Not generated. `scripts/smoke-test.sh ` copies every `smoke/*.swift`
+// plus `smoke/tier/*.swift` into the generated SmokeTests target before
+// running `swift test`. Only installed for tier >= 5 (Gtk's module).
+
+import Testing
+
+import GLib
+import GObject
+import Gtk
+
+@Suite("Tier 5 Gtk smoke tests")
+struct GtkSmokeTests {
+ @Test("getMajorVersion()/getMinorVersion() reach the real gtk_get_major_version/gtk_get_minor_version and report GTK 4")
+ func versionFunctionsReachRealGtk() throws {
+ #expect(getMajorVersion() == 4)
+ // GTK 4's minor version is a real, non-placeholder value reported by
+ // the linked library, not a stubbed default.
+ #expect(getMinorVersion() >= 0)
+ }
+
+ @Test("Adjustment(...) reaches the real gtk_adjustment_new and constructs a live, display-free GObject")
+ func adjustmentConstructsAndRoundTrips() throws {
+ let adjustment = Adjustment(value: 5, lower: 0, upper: 10, stepIncrement: 1, pageIncrement: 2, pageSize: 0)
+ #expect(adjustment.getValue() == 5)
+ #expect(adjustment.getLower() == 0)
+ #expect(adjustment.getUpper() == 10)
+
+ // Property computed-var path (GValue-free — delegates to the real
+ // getter/setter methods above) round-trips through the real C call.
+ adjustment.value = 7
+ #expect(adjustment.value == 7)
+ #expect(adjustment.getValue() == 7)
+ }
+}