fix: Linux compatibility for Terminal, SignalManager, and AppStorage

- Replace #if os(Linux) with #if canImport(Glibc/Musl/Darwin) import guards
- Add TermFlag typealias to fix termios flag width mismatch (UInt vs UInt32)
- Add platform import guard to SignalManager for POSIX signal visibility
- Replace DispatchQueue.global with Task.detached in AppStorage
- Add XDG_CONFIG_HOME fallback for config directory resolution
- Add Linux Build & Test CI job (swift:6.0 container on ubuntu-latest)
This commit is contained in:
phranck
2026-01-30 16:40:45 +01:00
parent e481899753
commit 3ec23004b9
4 changed files with 70 additions and 17 deletions
+15
View File
@@ -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
+8
View File
@@ -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.
+28 -10
View File
@@ -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/<appName>` (Linux convention)
/// 2. `~/.config/<appName>` (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<Value: Codable>: @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)
+19 -7
View File
@@ -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