diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml new file mode 100644 index 00000000..a8be2637 --- /dev/null +++ b/.github/workflows/linux.yml @@ -0,0 +1,22 @@ +name: Linux + +on: + push: + branches: [main] + pull_request: + merge_group: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Linux build + runs-on: ubuntu-latest + container: swift:6.2 + + steps: + - uses: actions/checkout@v4 + - run: swift build + - run: .build/debug/graphcode diff --git a/GraphcodeKit/Sources/AwakeAssertion.swift b/GraphcodeKit/Sources/AwakeAssertion.swift index d317d444..d94c3ed4 100644 --- a/GraphcodeKit/Sources/AwakeAssertion.swift +++ b/GraphcodeKit/Sources/AwakeAssertion.swift @@ -1,5 +1,7 @@ import Foundation -import IOKit.pwr_mgt +#if canImport(Darwin) + import IOKit.pwr_mgt +#endif /// Keeps the Mac from falling asleep while loops are actually running — the power /// assertion `caffeinate -i` takes, held by the daemon rather than by a person at a @@ -24,7 +26,13 @@ import IOKit.pwr_mgt public actor AwakeAssertion { public static let shared = AwakeAssertion() - private var held: IOPMAssertionID? + #if canImport(Darwin) + private var held: IOPMAssertionID? + #else + // No IOKit power assertions on Linux: nothing is ever held, `apply` does nothing, + // and the daemon's call site stays platform-independent. + private var held: Void? + #endif /// Whether the machine should be kept awake right now. Pure, and separate from the /// IOKit call, because the interesting part is the decision: a setting that is off @@ -37,24 +45,26 @@ public actor AwakeAssertion { /// and releasing while released both do nothing, so the caller can simply state the /// current answer on every graph change without tracking edges itself. public func apply(shouldHold: Bool, runningLoops: Int) { - if shouldHold { - guard held == nil else { return } - var identifier = IOPMAssertionID(0) - let reason = - runningLoops == 1 - ? "a GraphCode loop is running" : "\(runningLoops) GraphCode loops are running" - let result = IOPMAssertionCreateWithName( - kIOPMAssertionTypePreventUserIdleSystemSleep as CFString, - IOPMAssertionLevel(kIOPMAssertionLevelOn), reason as CFString, &identifier) - // A refused assertion is not worth failing anything over: the machine sleeps as it - // did before this existed, which is the behaviour every release until now had. - guard result == kIOReturnSuccess else { return } - held = identifier - } else { - guard let identifier = held else { return } - held = nil - IOPMAssertionRelease(identifier) - } + #if canImport(Darwin) + if shouldHold { + guard held == nil else { return } + var identifier = IOPMAssertionID(0) + let reason = + runningLoops == 1 + ? "a GraphCode loop is running" : "\(runningLoops) GraphCode loops are running" + let result = IOPMAssertionCreateWithName( + kIOPMAssertionTypePreventUserIdleSystemSleep as CFString, + IOPMAssertionLevel(kIOPMAssertionLevelOn), reason as CFString, &identifier) + // A refused assertion is not worth failing anything over: the machine sleeps as it + // did before this existed, which is the behaviour every release until now had. + guard result == kIOReturnSuccess else { return } + held = identifier + } else { + guard let identifier = held else { return } + held = nil + IOPMAssertionRelease(identifier) + } + #endif } /// Whether an assertion is currently held — for the daemon's own logging and for a test diff --git a/GraphcodeKit/Sources/DaemonBootstrap.swift b/GraphcodeKit/Sources/DaemonBootstrap.swift index 867afdc0..6542f3f8 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. @@ -435,3 +440,5 @@ public enum DaemonBootstrap { return process.terminationStatus } } + +#endif diff --git a/GraphcodeKit/Sources/IPC/DaemonSocketClient.swift b/GraphcodeKit/Sources/IPC/DaemonSocketClient.swift index bcc6cfe7..09d47538 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)) applyNoSignal(to: descriptor) @@ -139,9 +148,15 @@ public struct DaemonSocketClient: Sendable { /// invocation that would rather exit 75 and say so. With this the `write(2)` returns /// `EPIPE`, `FramedMessageIO` throws, and every caller's existing error path runs. private static func applyNoSignal(to descriptor: Int32) { - var enabled: Int32 = 1 - setsockopt( - descriptor, SOL_SOCKET, SO_NOSIGPIPE, &enabled, socklen_t(MemoryLayout.size)) + #if canImport(Darwin) + var enabled: Int32 = 1 + setsockopt( + descriptor, SOL_SOCKET, SO_NOSIGPIPE, &enabled, socklen_t(MemoryLayout.size)) + #else + // Linux has no per-socket SO_NOSIGPIPE; ignoring SIGPIPE process-wide is the + // equivalent armour, so the write(2) returns EPIPE instead of killing the process. + signal(SIGPIPE, SIG_IGN) + #endif } public func send(_ command: DaemonCommand) throws { diff --git a/GraphcodeKit/Sources/IPC/FramedMessageIO.swift b/GraphcodeKit/Sources/IPC/FramedMessageIO.swift index e33f9ebc..00231b89 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 080fc8dd..d0bc1692 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 { @@ -71,6 +76,9 @@ public final class PTYProcessSession: @unchecked Sendable { ) throws { var master: Int32 = 0 var slave: Int32 = 0 + // 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 } diff --git a/GraphcodeKit/Sources/Workspace.swift b/GraphcodeKit/Sources/Workspace.swift index 2e1a24fc..7ec47ff9 100644 --- a/GraphcodeKit/Sources/Workspace.swift +++ b/GraphcodeKit/Sources/Workspace.swift @@ -347,7 +347,9 @@ extension Workspace { ) throws -> Workspace { let destination = try Workspace.validate(name: name, home: home, fileManager: fileManager) .get() - DaemonBootstrap.removeLaunchAgent(for: self) + #if os(macOS) + DaemonBootstrap.removeLaunchAgent(for: self) + #endif try fileManager.moveItem(at: url, to: destination.url) return destination } diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 00000000..3ace2163 --- /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 00000000..1c24e4db --- /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 5408bd4f..e58688ed 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 @@ -202,9 +211,11 @@ DispatchQueue.global().async { guard clientDescriptor >= 0 else { continue } // Belt and braces beside the process-wide ignore above: this socket raises no // SIGPIPE whatever any library does to the signal disposition later. - var noSignal: Int32 = 1 - setsockopt( - clientDescriptor, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout.size)) + #if canImport(Darwin) + var noSignal: Int32 = 1 + setsockopt( + clientDescriptor, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout.size)) + #endif handleConnection(clientDescriptor) } }