diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c40a647..c0bc9a8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,21 @@ jobs: - name: Run SwiftLint run: swiftlint + linux-build: + name: Linux Build & Test + runs-on: ubuntu-latest + container: + image: swift:6.0 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build + run: swift build + + - name: Test + run: swift test + build-docs: name: Build DocC needs: lint diff --git a/Sources/TUIKit/App/SignalManager.swift b/Sources/TUIKit/App/SignalManager.swift index 336c21c8..d40f447a 100644 --- a/Sources/TUIKit/App/SignalManager.swift +++ b/Sources/TUIKit/App/SignalManager.swift @@ -7,6 +7,14 @@ import Foundation +#if canImport(Glibc) + import Glibc +#elseif canImport(Musl) + import Musl +#elseif canImport(Darwin) + import Darwin +#endif + // MARK: - Signal Flags /// Flag set by the SIGWINCH signal handler to request a re-render. diff --git a/Sources/TUIKit/Core/AppStorage.swift b/Sources/TUIKit/Core/AppStorage.swift index f9d6314a..690bdaca 100644 --- a/Sources/TUIKit/Core/AppStorage.swift +++ b/Sources/TUIKit/Core/AppStorage.swift @@ -24,12 +24,36 @@ public protocol StorageBackend: Sendable { func synchronize() } +// MARK: - Config Directory + +/// Returns the app-specific configuration directory. +/// +/// Resolves the directory in this order: +/// 1. `$XDG_CONFIG_HOME/` (Linux convention) +/// 2. `~/.config/` (fallback) +/// +/// This ensures correct behavior on Linux where `$XDG_CONFIG_HOME` +/// may differ from `~/.config`. +private func appConfigDirectory() -> URL { + let appName = ProcessInfo.processInfo.processName + + if let xdgConfig = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"], !xdgConfig.isEmpty { + return URL(fileURLWithPath: xdgConfig) + .appendingPathComponent(appName) + } + + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config") + .appendingPathComponent(appName) +} + // MARK: - JSON File Storage /// A storage backend that persists data to a JSON file. /// /// This is the default storage backend for TUIKit apps. -/// Data is stored in `~/.config/[appName]/settings.json`. +/// Data is stored in `$XDG_CONFIG_HOME/[appName]/settings.json` +/// or `~/.config/[appName]/settings.json` as fallback. public final class JSONFileStorage: StorageBackend, @unchecked Sendable { /// The shared instance. public static let shared = JSONFileStorage() @@ -45,10 +69,7 @@ public final class JSONFileStorage: StorageBackend, @unchecked Sendable { /// Creates a JSON file storage with default location. public init() { - let appName = ProcessInfo.processInfo.processName - let configDir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".config") - .appendingPathComponent(appName) + let configDir = appConfigDirectory() // Create directory if needed try? FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) @@ -125,7 +146,7 @@ public final class JSONFileStorage: StorageBackend, @unchecked Sendable { } private func saveToDiskAsync() { - DispatchQueue.global(qos: .utility).async { [weak self] in + Task.detached(priority: .utility) { [weak self] in self?.saveToDiskSync() } } @@ -327,10 +348,7 @@ public struct SceneStorage: @unchecked Sendable { /// Scene-specific storage file. private static var sceneStorage: JSONFileStorage { - let appName = ProcessInfo.processInfo.processName - let configDir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".config") - .appendingPathComponent(appName) + let configDir = appConfigDirectory() try? FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) diff --git a/Sources/TUIKit/Rendering/Terminal.swift b/Sources/TUIKit/Rendering/Terminal.swift index 3e4b1fff..b66ba6f1 100644 --- a/Sources/TUIKit/Rendering/Terminal.swift +++ b/Sources/TUIKit/Rendering/Terminal.swift @@ -7,12 +7,24 @@ import Foundation -#if os(Linux) +#if canImport(Glibc) import Glibc -#else +#elseif canImport(Musl) + import Musl +#elseif canImport(Darwin) import Darwin #endif +/// Platform-specific type for `termios` flag fields. +/// +/// Darwin uses `UInt` (64-bit), Linux uses `tcflag_t` (`UInt32`). +/// This typealias ensures flag bitmask operations compile on both. +#if os(Linux) + private typealias TermFlag = UInt32 +#else + private typealias TermFlag = UInt +#endif + /// Represents the terminal and controls input and output. /// /// `Terminal` is the central interface to the terminal. It provides: @@ -57,7 +69,7 @@ public final class Terminal: @unchecked Sendable { public func getSize() -> (width: Int, height: Int) { var windowSize = winsize() - #if os(Linux) + #if canImport(Glibc) || canImport(Musl) let result = ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &windowSize) #else let result = ioctl(STDOUT_FILENO, TIOCGWINSZ, &windowSize) @@ -94,7 +106,7 @@ public final class Terminal: @unchecked Sendable { // ICANON: Canonical mode (line by line) // ISIG: Ctrl+C/Ctrl+Z signals // IEXTEN: Ctrl+V - raw.c_lflag &= ~(UInt(ECHO | ICANON | ISIG | IEXTEN)) + raw.c_lflag &= ~TermFlag(ECHO | ICANON | ISIG | IEXTEN) // Disable: // IXON: Ctrl+S/Ctrl+Q software flow control @@ -102,13 +114,13 @@ public final class Terminal: @unchecked Sendable { // BRKINT: Break signal // INPCK: Parity check // ISTRIP: Strip 8th bit - raw.c_iflag &= ~(UInt(IXON | ICRNL | BRKINT | INPCK | ISTRIP)) + raw.c_iflag &= ~TermFlag(IXON | ICRNL | BRKINT | INPCK | ISTRIP) // Disable output processing - raw.c_oflag &= ~(UInt(OPOST)) + raw.c_oflag &= ~TermFlag(OPOST) // Set character size to 8 bits - raw.c_cflag |= UInt(CS8) + raw.c_cflag |= TermFlag(CS8) // Set timeouts: VMIN=0, VTIME=1 (100ms timeout) // c_cc is a tuple in Swift, so we need to use withUnsafeMutablePointer