From 4ff4fffa0b5e7f7335dc9786b85b2c5f0843ec1e Mon Sep 17 00:00:00 2001 From: Sravani Pagidela Date: Fri, 14 Aug 2026 21:08:23 -0700 Subject: [PATCH 1/2] feat: build GraphcodeKit, CLI, and graphcoded on Linux (#83) Adds a root Package.swift so the non-UI products build with SwiftPM on Linux, guards the Darwin-only code paths (openpty, sun_len, SOCK_STREAM, launchd bootstrap), and adds a Linux CI workflow as the portability gate. The macOS app keeps building through Tuist, which resolves its dependencies from Tuist/Package.swift and ignores the root manifest. Co-Authored-By: Claude Fable 5 --- .github/workflows/linux.yml | 20 ++++++++ GraphcodeKit/Sources/DaemonBootstrap.swift | 7 +++ .../Sources/IPC/DaemonSocketClient.swift | 15 ++++-- .../Sources/IPC/FramedMessageIO.swift | 2 + .../Sources/Sessions/PTYProcessSession.swift | 30 ++++++++++-- Package.resolved | 24 ++++++++++ Package.swift | 47 +++++++++++++++++++ graphcoded/Sources/main.swift | 13 ++++- 8 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/linux.yml create mode 100644 Package.resolved create mode 100644 Package.swift diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml new file mode 100644 index 0000000..21fb3f2 --- /dev/null +++ b/.github/workflows/linux.yml @@ -0,0 +1,20 @@ +# Builds the non-UI products (GraphcodeKit, graphcode CLI, graphcoded) on Linux via +# the root Package.swift — the portability gate for issue #83. The macOS app still +# builds only through Tuist/Xcode and is not covered here. +name: Linux + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + container: swift:6.2 + steps: + - uses: actions/checkout@v4 + - name: Build + run: swift build + - name: Smoke-test CLI + run: .build/debug/graphcode diff --git a/GraphcodeKit/Sources/DaemonBootstrap.swift b/GraphcodeKit/Sources/DaemonBootstrap.swift index 404f7ed..ebea15e 100644 --- a/GraphcodeKit/Sources/DaemonBootstrap.swift +++ b/GraphcodeKit/Sources/DaemonBootstrap.swift @@ -1,5 +1,10 @@ import Foundation +// launchd, quarantine xattrs, `~/Library/LaunchAgents` — this whole mechanism is the +// macOS app's drag-to-Applications install, and only the app calls it. A Linux install +// story (systemd user unit or equivalent) would be a sibling, not a port of this. +#if os(macOS) + /// Installs the helpers a shipped `graphcode.app` carries inside itself — `graphcoded` and /// `zmx` — and loads the daemon, so dragging the app to `/Applications` is the whole /// installation. @@ -226,3 +231,5 @@ public enum DaemonBootstrap { process.waitUntilExit() } } + +#endif diff --git a/GraphcodeKit/Sources/IPC/DaemonSocketClient.swift b/GraphcodeKit/Sources/IPC/DaemonSocketClient.swift index a9b9470..99a2558 100644 --- a/GraphcodeKit/Sources/IPC/DaemonSocketClient.swift +++ b/GraphcodeKit/Sources/IPC/DaemonSocketClient.swift @@ -2,6 +2,8 @@ import Foundation #if canImport(Darwin) import Darwin +#else + import Glibc #endif /// A short-lived client for `graphcoded`'s socket — what the `graphcode` CLI talks @@ -90,12 +92,19 @@ public struct DaemonSocketClient: Sendable { throw ClientError.daemonNotRunning } - let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + #if canImport(Darwin) + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + #else + // Glibc imports SOCK_STREAM as the `__socket_type` enum, not an Int32. + let descriptor = socket(AF_UNIX, Int32(SOCK_STREAM.rawValue), 0) + #endif guard descriptor >= 0 else { throw ClientError.connectionFailed(errno: errno) } var address = sockaddr_un() address.sun_family = sa_family_t(AF_UNIX) - address.sun_len = UInt8(MemoryLayout.size) + #if canImport(Darwin) + address.sun_len = UInt8(MemoryLayout.size) + #endif withUnsafeMutablePointer(to: &address.sun_path) { field in field.withMemoryRebound( to: CChar.self, capacity: MemoryLayout.size(ofValue: field.pointee) @@ -127,7 +136,7 @@ public struct DaemonSocketClient: Sendable { private static func applyReceiveTimeout(_ timeout: TimeInterval, to descriptor: Int32) { var interval = timeval( tv_sec: Int(timeout), - tv_usec: Int32((timeout - timeout.rounded(.down)) * 1_000_000)) + tv_usec: suseconds_t((timeout - timeout.rounded(.down)) * 1_000_000)) setsockopt( descriptor, SOL_SOCKET, SO_RCVTIMEO, &interval, socklen_t(MemoryLayout.size)) } diff --git a/GraphcodeKit/Sources/IPC/FramedMessageIO.swift b/GraphcodeKit/Sources/IPC/FramedMessageIO.swift index d25206e..93d9f74 100644 --- a/GraphcodeKit/Sources/IPC/FramedMessageIO.swift +++ b/GraphcodeKit/Sources/IPC/FramedMessageIO.swift @@ -2,6 +2,8 @@ import Foundation #if canImport(Darwin) import Darwin +#else + import Glibc #endif /// Length-prefixed framing over a raw socket file descriptor — a 4-byte big-endian diff --git a/GraphcodeKit/Sources/Sessions/PTYProcessSession.swift b/GraphcodeKit/Sources/Sessions/PTYProcessSession.swift index 4d66304..22de888 100644 --- a/GraphcodeKit/Sources/Sessions/PTYProcessSession.swift +++ b/GraphcodeKit/Sources/Sessions/PTYProcessSession.swift @@ -1,6 +1,11 @@ -import Darwin import Foundation +#if canImport(Darwin) + import Darwin +#else + import Glibc +#endif + /// One event out of a running PTY-backed process: either a chunk of raw output, or a /// terminal lifecycle transition. See docs/04-cli-backends.md. public enum PTYSessionEvent: Sendable, Equatable { @@ -40,9 +45,26 @@ public final class PTYProcessSession: @unchecked Sendable { ) throws { var master: Int32 = 0 var slave: Int32 = 0 - guard openpty(&master, &slave, nil, nil, nil) == 0 else { - throw SessionError.failedToOpenPTY - } + #if canImport(Darwin) + guard openpty(&master, &slave, nil, nil, nil) == 0 else { + throw SessionError.failedToOpenPTY + } + #else + // `openpty` lives in libutil on Linux, which the Glibc module doesn't reliably + // expose — the POSIX pt* trio is the same operation without the extra library. + master = posix_openpt(O_RDWR) + guard master >= 0, grantpt(master) == 0, unlockpt(master) == 0, + let slaveName = ptsname(master).map({ String(cString: $0) }) + else { + if master >= 0 { close(master) } + throw SessionError.failedToOpenPTY + } + slave = open(slaveName, O_RDWR) + guard slave >= 0 else { + close(master) + throw SessionError.failedToOpenPTY + } + #endif let masterHandle = FileHandle(fileDescriptor: master, closeOnDealloc: true) let slaveHandle = FileHandle(fileDescriptor: slave, closeOnDealloc: false) diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..3ace216 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "2ef7a1c0cce984b320f4784a7c1bd827ec8f3e73eaba10904b94d12d131c9bea", + "pins" : [ + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-identified-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-identified-collections", + "state" : { + "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", + "version" : "1.1.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..1c24e4d --- /dev/null +++ b/Package.swift @@ -0,0 +1,47 @@ +// swift-tools-version: 6.0 +import PackageDescription + +// SwiftPM manifest for the non-UI products — GraphcodeKit, the `graphcode` CLI, and +// `graphcoded` — so they build on Linux (and anywhere else swift-corelibs Foundation +// runs), where Tuist and Xcode don't. The macOS app keeps building through +// `Project.swift`; Tuist resolves its dependencies from `Tuist/Package.swift` and +// ignores this file. See issue #83. +let package = Package( + name: "graphcode", + platforms: [.macOS(.v15)], + products: [ + .library(name: "GraphcodeKit", targets: ["GraphcodeKit"]), + .executable(name: "graphcode", targets: ["graphcode-cli"]), + .executable(name: "graphcoded", targets: ["graphcoded"]), + ], + dependencies: [ + .package( + url: "https://github.com/pointfreeco/swift-identified-collections", + from: "1.1.0" + ) + ], + targets: [ + .target( + name: "GraphcodeKit", + dependencies: [ + .product(name: "IdentifiedCollections", package: "swift-identified-collections") + ], + path: "GraphcodeKit/Sources", + // Language mode 5 to match how Tuist/Xcode builds these same sources today; + // moving the tree to strict mode 6 is its own change, not the Linux port's. + swiftSettings: [.swiftLanguageMode(.v5)] + ), + .executableTarget( + name: "graphcode-cli", + dependencies: ["GraphcodeKit"], + path: "graphcode-cli/Sources", + swiftSettings: [.swiftLanguageMode(.v5)] + ), + .executableTarget( + name: "graphcoded", + dependencies: ["GraphcodeKit"], + path: "graphcoded/Sources", + swiftSettings: [.swiftLanguageMode(.v5)] + ), + ] +) diff --git a/graphcoded/Sources/main.swift b/graphcoded/Sources/main.swift index 5258a86..f871017 100644 --- a/graphcoded/Sources/main.swift +++ b/graphcoded/Sources/main.swift @@ -3,6 +3,8 @@ import GraphcodeKit #if canImport(Darwin) import Darwin +#else + import Glibc #endif // graphcoded — the graphcode orchestrator daemon. @@ -37,14 +39,21 @@ func fail(_ message: String) -> Never { exit(1) } -let socketDescriptor = socket(AF_UNIX, SOCK_STREAM, 0) +#if canImport(Darwin) + let socketDescriptor = socket(AF_UNIX, SOCK_STREAM, 0) +#else + // Glibc imports SOCK_STREAM as the `__socket_type` enum, not an Int32. + let socketDescriptor = socket(AF_UNIX, Int32(SOCK_STREAM.rawValue), 0) +#endif guard socketDescriptor >= 0 else { fail("failed to create socket (errno \(errno))") } var address = sockaddr_un() address.sun_family = sa_family_t(AF_UNIX) -address.sun_len = UInt8(MemoryLayout.size) +#if canImport(Darwin) + address.sun_len = UInt8(MemoryLayout.size) +#endif let path = socketURL.path withUnsafeMutablePointer(to: &address.sun_path) { pathField in From 9353eb3d941b0b1294b85e4b3d42a5f6211a6e9b Mon Sep 17 00:00:00 2001 From: Sravani Pagidela Date: Fri, 14 Aug 2026 21:12:06 -0700 Subject: [PATCH 2/2] fix: open the PTY with openpty on Linux too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit posix_openpt/grantpt/unlockpt/ptsname never make it through Swift's Glibc module (stdlib.h feature-macro guards), while pty.h — and with glibc >= 2.34 libc itself — provides openpty on both platforms. Co-Authored-By: Claude Fable 5 --- .../Sources/Sessions/PTYProcessSession.swift | 26 +++++-------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/GraphcodeKit/Sources/Sessions/PTYProcessSession.swift b/GraphcodeKit/Sources/Sessions/PTYProcessSession.swift index 22de888..28414fa 100644 --- a/GraphcodeKit/Sources/Sessions/PTYProcessSession.swift +++ b/GraphcodeKit/Sources/Sessions/PTYProcessSession.swift @@ -45,26 +45,12 @@ public final class PTYProcessSession: @unchecked Sendable { ) throws { var master: Int32 = 0 var slave: Int32 = 0 - #if canImport(Darwin) - guard openpty(&master, &slave, nil, nil, nil) == 0 else { - throw SessionError.failedToOpenPTY - } - #else - // `openpty` lives in libutil on Linux, which the Glibc module doesn't reliably - // expose — the POSIX pt* trio is the same operation without the extra library. - master = posix_openpt(O_RDWR) - guard master >= 0, grantpt(master) == 0, unlockpt(master) == 0, - let slaveName = ptsname(master).map({ String(cString: $0) }) - else { - if master >= 0 { close(master) } - throw SessionError.failedToOpenPTY - } - slave = open(slaveName, O_RDWR) - guard slave >= 0 else { - close(master) - throw SessionError.failedToOpenPTY - } - #endif + // Portable as-is: Glibc's module includes pty.h, and glibc ≥ 2.34 ships openpty + // in libc proper, so no libutil link is needed on Linux. The POSIX pt* trio is + // not an option — stdlib.h's feature-macro guards keep it out of Swift's Glibc. + guard openpty(&master, &slave, nil, nil, nil) == 0 else { + throw SessionError.failedToOpenPTY + } let masterHandle = FileHandle(fileDescriptor: master, closeOnDealloc: true) let slaveHandle = FileHandle(fileDescriptor: slave, closeOnDealloc: false)