Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/linux.yml
Original file line number Diff line number Diff line change
@@ -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
50 changes: 30 additions & 20 deletions GraphcodeKit/Sources/AwakeAssertion.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions GraphcodeKit/Sources/DaemonBootstrap.swift
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -435,3 +440,5 @@ public enum DaemonBootstrap {
return process.terminationStatus
}
}

#endif
27 changes: 21 additions & 6 deletions GraphcodeKit/Sources/IPC/DaemonSocketClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<sockaddr_un>.size)
#if canImport(Darwin)
address.sun_len = UInt8(MemoryLayout<sockaddr_un>.size)
#endif
withUnsafeMutablePointer(to: &address.sun_path) { field in
field.withMemoryRebound(
to: CChar.self, capacity: MemoryLayout.size(ofValue: field.pointee)
Expand Down Expand Up @@ -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<timeval>.size))
applyNoSignal(to: descriptor)
Expand All @@ -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<Int32>.size))
#if canImport(Darwin)
var enabled: Int32 = 1
setsockopt(
descriptor, SOL_SOCKET, SO_NOSIGPIPE, &enabled, socklen_t(MemoryLayout<Int32>.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 {
Expand Down
2 changes: 2 additions & 0 deletions GraphcodeKit/Sources/IPC/FramedMessageIO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion GraphcodeKit/Sources/Sessions/PTYProcessSession.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
4 changes: 3 additions & 1 deletion GraphcodeKit/Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
24 changes: 24 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 47 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -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)]
),
]
)
21 changes: 16 additions & 5 deletions graphcoded/Sources/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import GraphcodeKit

#if canImport(Darwin)
import Darwin
#else
import Glibc
#endif

// graphcoded — the graphcode orchestrator daemon.
Expand Down Expand Up @@ -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<sockaddr_un>.size)
#if canImport(Darwin)
address.sun_len = UInt8(MemoryLayout<sockaddr_un>.size)
#endif

let path = socketURL.path
withUnsafeMutablePointer(to: &address.sun_path) { pathField in
Expand Down Expand Up @@ -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<Int32>.size))
#if canImport(Darwin)
var noSignal: Int32 = 1
setsockopt(
clientDescriptor, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout<Int32>.size))
#endif
handleConnection(clientDescriptor)
}
}
Expand Down
Loading