├── .gitignore ├── .swiftlint.yml ├── Package.swift ├── README.md └── Sources ├── setraw ├── include │ └── setraw.h └── setraw.c └── sigswift ├── App.swift ├── Control.swift ├── Event.swift ├── Signal.swift ├── main.swift └── readCharacter.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | trailing_comma: 2 | mandatory_comma: true 3 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:4.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "sigswift", 7 | targets: [ 8 | .target( 9 | name: "sigswift", 10 | dependencies: ["setraw"] 11 | ), 12 | .target( 13 | name: "setraw", 14 | dependencies: [] 15 | ), 16 | ] 17 | ) 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sigswift 2 | 3 | This was actually an excuse to design a completely event-driven terminal program in Swift. 4 | It's inspired by the [Elm Architecture][]. 5 | 6 | [![asciicast](https://asciinema.org/a/151764.png)](https://asciinema.org/a/151764?t=13) 7 | 8 | How it works: 9 | 10 | - Sets the terminal to [raw mode][], to enable processing input 11 | character-by-character instead of line-by-line. 12 | 13 | - Configures a Grand Central Dispatch (GCD) `DispatchReadSource` to read from 14 | standard input one character at a time. 15 | 16 | - Configures a number of GCD `DispatchSignalSource`s to process signals as 17 | events. 18 | 19 | - With the event handlers in place, models the application as an `App` struct 20 | that receives an `Event`, optionally updates its state, and dispatches an 21 | array of `Action`. 22 | 23 | - No side effects are implemented in `App`, but are instead implemented in 24 | top-level functions `handle()` and `dispatch()`, which convert `Action`s 25 | into side effects. 26 | 27 | [Elm Architecture]: https://talk.objc.io/episodes/S01E66-the-elm-architecture-part-1 28 | [raw mode]: https://viewsourcecode.org/snaptoken/kilo/02.enteringRawMode.html 29 | -------------------------------------------------------------------------------- /Sources/setraw/include/setraw.h: -------------------------------------------------------------------------------- 1 | void setraw(void); 2 | -------------------------------------------------------------------------------- /Sources/setraw/setraw.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | static struct termios orig_termios; 7 | 8 | void unsetraw(void) { 9 | if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_termios) == -1) { 10 | err(1, NULL); 11 | } 12 | } 13 | 14 | void setraw(void) { 15 | if (tcgetattr(STDIN_FILENO, &orig_termios) == -1) { 16 | err(1, NULL); 17 | } 18 | atexit(unsetraw); 19 | 20 | struct termios raw = orig_termios; 21 | raw.c_cflag |= (CS8); 22 | raw.c_iflag &= ~(BRKINT | INPCK | ISTRIP | IXON); 23 | raw.c_lflag &= ~(ECHO | ICANON | IEXTEN); 24 | 25 | if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) == -1) { 26 | err(1, NULL); 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /Sources/sigswift/App.swift: -------------------------------------------------------------------------------- 1 | import func Darwin.C.iscntrl 2 | import enum Dispatch.DispatchTimeInterval 3 | 4 | struct App { 5 | enum Action { 6 | case die(Error) 7 | case exit 8 | case schedule(Event, after: DispatchTimeInterval) 9 | case print(String) 10 | } 11 | 12 | private var confirming = false 13 | 14 | mutating func handleEvent(_ event: Event) -> [Action] { 15 | switch event { 16 | case .cancelExit: 17 | confirming = false 18 | return [.print("\n")] 19 | 20 | case let .init(processInfo): 21 | return [ 22 | .print("Process ID: \(processInfo.processIdentifier)\n"), 23 | .print("Try sending a signal!\n"), 24 | ] 25 | 26 | case let .keyboardReadError(error): 27 | return [.die(error)] 28 | 29 | case .keyboard where self.confirming: 30 | return [] 31 | 32 | case .keyboard(.EOT): 33 | return [.exit] 34 | 35 | case let .keyboard(character) where character.isLineSeparator: 36 | return [.print("\n")] 37 | 38 | case let .keyboard(character) where character.isBackspace: 39 | return [.print(.destructiveBackspace)] 40 | 41 | case let .keyboard(character) where character.isControlCode: 42 | return [.print(character.escaped(asASCII: false))] 43 | 44 | case let .keyboard(character): 45 | return [.print(String(character))] 46 | 47 | case .signal(.INT) where self.confirming: 48 | return [.print("\n"), .exit] 49 | 50 | case .signal(.INT): 51 | confirming = true 52 | return [.print("Exit? (^C to confirm)"), .schedule(.cancelExit, after: .seconds(2))] 53 | 54 | case .signal(.TERM): 55 | return [ 56 | .print("Terminating...\n"), 57 | .exit, 58 | ] 59 | 60 | case let .signal(signal): 61 | let description = String(reflecting: signal) 62 | let name = description.split(separator: ".").last! 63 | return [.print("Received \(name)\n")] 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/sigswift/Control.swift: -------------------------------------------------------------------------------- 1 | // swiftlint:disable identifier_name 2 | 3 | import func Darwin.C.iscntrl 4 | 5 | extension String { 6 | static let destructiveBackspace = "\u{8}\u{20}\u{8}" 7 | } 8 | 9 | extension UnicodeScalar { 10 | static let EOT = UnicodeScalar(4 as UInt8) 11 | static let BS = UnicodeScalar(8 as UInt8) 12 | static let LF = UnicodeScalar(10 as UInt8) 13 | static let CR = UnicodeScalar(13 as UInt8) 14 | 15 | var isBackspace: Bool { 16 | return self == .BS || self == "\u{7F}" 17 | } 18 | 19 | var isControlCode: Bool { 20 | return isASCII && iscntrl(Int32(value)) != 0 21 | } 22 | 23 | var isLineSeparator: Bool { 24 | return self == .LF || self == .CR 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/sigswift/Event.swift: -------------------------------------------------------------------------------- 1 | import class Foundation.ProcessInfo 2 | 3 | enum Event { 4 | case cancelExit 5 | case `init`(ProcessInfo) 6 | case keyboard(UnicodeScalar) 7 | case keyboardReadError(Error) 8 | case signal(Signal) 9 | } 10 | -------------------------------------------------------------------------------- /Sources/sigswift/Signal.swift: -------------------------------------------------------------------------------- 1 | // From signal(3): 2 | // 3 | // 1 SIGHUP terminate process terminal line hangup 4 | // 2 SIGINT terminate process interrupt program 5 | // 3 SIGQUIT create core image quit program 6 | // 4 SIGILL create core image illegal instruction 7 | // 5 SIGTRAP create core image trace trap 8 | // 6 SIGABRT create core image abort program (formerly SIGIOT) 9 | // 7 SIGEMT create core image emulate instruction executed 10 | // 8 SIGFPE create core image floating-point exception 11 | // 9 SIGKILL terminate process kill program 12 | // 10 SIGBUS create core image bus error 13 | // 11 SIGSEGV create core image segmentation violation 14 | // 12 SIGSYS create core image non-existent system call invoked 15 | // 13 SIGPIPE terminate process write on a pipe with no reader 16 | // 14 SIGALRM terminate process real-time timer expired 17 | // 15 SIGTERM terminate process software termination signal 18 | // 16 SIGURG discard signal urgent condition present on socket 19 | // 17 SIGSTOP stop process stop (cannot be caught or ignored) 20 | // 18 SIGTSTP stop process stop signal generated from keyboard 21 | // 19 SIGCONT discard signal continue after stop 22 | // 20 SIGCHLD discard signal child status has changed 23 | // 21 SIGTTIN stop process background read attempted from control terminal 24 | // 22 SIGTTOU stop process background write attempted to control terminal 25 | // 23 SIGIO discard signal I/O is possible on a descriptor (see fcntl(2)) 26 | // 24 SIGXCPU terminate process cpu time limit exceeded (see setrlimit(2)) 27 | // 25 SIGXFSZ terminate process file size limit exceeded (see setrlimit(2)) 28 | // 26 SIGVTALRM terminate process virtual time alarm (see setitimer(2)) 29 | // 27 SIGPROF terminate process profiling timer alarm (see setitimer(2)) 30 | // 28 SIGWINCH discard signal Window size change 31 | // 29 SIGINFO discard signal status request from keyboard 32 | // 30 SIGUSR1 terminate process User defined signal 1 33 | // 31 SIGUSR2 terminate process User defined signal 2 34 | 35 | import var Darwin.C.SIG_IGN 36 | import func Darwin.C.signal 37 | import class Dispatch.DispatchQueue 38 | import class Dispatch.DispatchSource 39 | import protocol Dispatch.DispatchSourceSignal 40 | 41 | enum Signal: Int32 { 42 | case HUP = 1 43 | case INT = 2 44 | case QUIT = 3 45 | case ILL = 4 46 | case TRAP = 5 47 | case ABRT = 6 48 | case EMT = 7 49 | case FPE = 8 50 | case KILL = 9 51 | case BUS = 10 52 | case SEGV = 11 53 | case SYS = 12 54 | case PIPE = 13 55 | case ALRM = 14 56 | case TERM = 15 57 | case URG = 16 58 | case STOP = 17 59 | case TSTP = 18 60 | case CONT = 19 61 | case CHLD = 20 62 | case TTIN = 21 63 | case TTOU = 22 64 | // swiftlint:disable:next identifier_name 65 | case IO = 23 66 | case XCPU = 24 67 | case XFSZ = 25 68 | case VTALRM = 26 69 | case PROF = 27 70 | case WINCH = 28 71 | case INFO = 29 72 | case USR1 = 30 73 | case USR2 = 31 74 | } 75 | 76 | extension Signal { 77 | static func registerHandler(signal: Signal, queue: DispatchQueue? = nil, handler: @escaping (Signal) -> Void) -> DispatchSourceSignal { 78 | Darwin.signal(signal.rawValue, SIG_IGN) 79 | 80 | let source = DispatchSource.makeSignalSource(signal: signal.rawValue, queue: queue) 81 | source.setEventHandler { handler(signal) } 82 | source.resume() 83 | return source 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/sigswift/main.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import setraw 3 | 4 | var app = App() 5 | 6 | func handle(_ event: Event) { 7 | let actions = app.handleEvent(event) 8 | dispatch(actions) 9 | } 10 | 11 | func dispatch(_ actions: [App.Action]) { 12 | for action in actions { 13 | switch action { 14 | case let .die(error): 15 | let message = String(describing: error) 16 | fputs(message + "\n", stderr) 17 | exit(1) 18 | 19 | case .exit: 20 | exit(0) 21 | 22 | case let .print(output): 23 | print(output, terminator: "") 24 | fflush(stdout) 25 | 26 | case let .schedule(event, after: interval): 27 | DispatchQueue.main.asyncAfter(deadline: .now() + interval) { 28 | handle(event) 29 | } 30 | } 31 | } 32 | } 33 | 34 | // Ensure app is initialised before any other event is handled. 35 | 36 | DispatchQueue.main.async { 37 | handle(.init(.processInfo)) 38 | } 39 | 40 | // Set up keyboard event source 41 | 42 | setraw() 43 | 44 | let keys = DispatchSource.makeReadSource(fileDescriptor: STDIN_FILENO, queue: nil) 45 | keys.setEventHandler { 46 | do { 47 | let character = try readCharacter() 48 | handle(.keyboard(character)) 49 | } catch POSIXError.EAGAIN { 50 | return 51 | } catch POSIXError.EINTR { 52 | return 53 | } catch { 54 | handle(.keyboardReadError(error)) 55 | } 56 | } 57 | keys.resume() 58 | 59 | // Set up signal sources 60 | 61 | func handleSignal(_ signal: Signal) { 62 | handle(.signal(signal)) 63 | } 64 | 65 | let hup = Signal.registerHandler(signal: .HUP, handler: handleSignal) 66 | let int = Signal.registerHandler(signal: .INT, handler: handleSignal) 67 | let term = Signal.registerHandler(signal: .TERM, handler: handleSignal) 68 | let usr1 = Signal.registerHandler(signal: .USR1, handler: handleSignal) 69 | let usr2 = Signal.registerHandler(signal: .USR2, handler: handleSignal) 70 | 71 | dispatchMain() 72 | -------------------------------------------------------------------------------- /Sources/sigswift/readCharacter.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | func readCharacter(fileDescriptor: Int32 = STDIN_FILENO) throws -> UnicodeScalar { 4 | var char = 0 as UInt8 5 | let bytesRead = withUnsafeMutablePointer(to: &char) { 6 | read(fileDescriptor, $0, 1) 7 | } 8 | 9 | switch bytesRead { 10 | case 0: 11 | return .EOT 12 | case 1: 13 | return UnicodeScalar(char) 14 | default: 15 | throw NSError(domain: POSIXError.errorDomain, code: Int(errno)) 16 | } 17 | } 18 | --------------------------------------------------------------------------------