Selkie.git | lib/Selkie/App/Internal/ | TerminalPlatform.rakumod
=begin pod
=head1 NAME
Selkie::App::Internal::TerminalPlatform - platform boundary for terminal lifecycle operations
=head1 DESCRIPTION
Internal implementation detail shared by C<Selkie::App> and
C<Selkie::Test::Snapshot>. All access to the POSIX controlling-terminal path,
termios ABI, flow-control command, and terminal-related signals is selected
here. Windows deliberately exposes no controlling-terminal or Raku signal
operations: its console is managed by notcurses and its resize events arrive as
input events rather than C<SIGWINCH>.
=end pod
unit module Selkie::App::Internal::TerminalPlatform;
use NativeCall;
use Selkie::App::Internal::PosixFD;
my constant TERMINAL-PATH = '/dev/tty';
# Opaque storage only: struct termios is 60 bytes on supported glibc/musl
# targets and 72 bytes on macOS arm64. Selkie never reads its fields.
my constant TERMIOS-BUF-BYTES = 256;
my constant TCSANOW = 0;
my constant O_RDONLY = 0;
# This pure selector is exported only for platform-contract tests. Signal names
# stay as strings until the selected, host-valid branch is used.
our sub terminal-platform-spec(Bool:D $is-win --> Map:D) is export(:test) {
$is-win
?? Map.new(
controlling-terminal => False,
resize-signals => ().List,
crash-signals => ().List,
)
!! Map.new(
controlling-terminal => True,
resize-signals => <SIGWINCH>.List,
crash-signals => <SIGABRT SIGTERM SIGHUP SIGQUIT>.List,
);
}
our constant IS-WINDOWS is export = $*DISTRO.is-win;
my constant PLATFORM-SPEC = terminal-platform-spec(IS-WINDOWS);
# POSIX-only. NativeCall resolves these lazily, and every public operation
# returns on Windows before it can reach a binding or the /dev/tty path.
# open(2) is variadic, but the mode argument is unused without O_CREAT; the two
# named arguments have the same ABI on all POSIX targets supported by Selkie.
sub c-tty-open(Str, int32 --> int32) is native(Str) is symbol('open') { * }
sub c-tcgetattr(int32, CArray[uint8] --> int32) is native(Str) is symbol('tcgetattr') { * }
sub c-tcsetattr(int32, int32, CArray[uint8] --> int32) is native(Str) is symbol('tcsetattr') { * }
our sub capture-controlling-terminal-state(--> CArray[uint8]) is export {
return CArray[uint8] if IS-WINDOWS;
return CArray[uint8] unless TERMINAL-PATH.IO.e;
my $fd = try c-tty-open(TERMINAL-PATH, O_RDONLY);
return CArray[uint8] unless $fd.defined && $fd >= 0;
my $state = CArray[uint8].allocate(TERMIOS-BUF-BYTES);
my $result = try c-tcgetattr($fd, $state);
my $ = try posix-fd-close($fd);
$result.defined && $result == 0 ?? $state !! CArray[uint8];
}
our sub restore-controlling-terminal-state(CArray[uint8] $state --> Nil) is export {
return if IS-WINDOWS;
return unless $state.defined;
return unless TERMINAL-PATH.IO.e;
my $fd = try c-tty-open(TERMINAL-PATH, O_RDONLY);
return unless $fd.defined && $fd >= 0;
my $ = try c-tcsetattr($fd, TCSANOW, $state);
my $ = try posix-fd-close($fd);
Nil;
}
our sub write-controlling-terminal(Str:D $text --> Nil) is export {
return if IS-WINDOWS;
return unless TERMINAL-PATH.IO.e;
my $tty = try open(TERMINAL-PATH, :w);
return without $tty;
my $ = try $tty.print($text);
my $ = try $tty.close;
Nil;
}
our sub disable-controlling-terminal-flow-control(--> Nil) is export {
return if IS-WINDOWS;
return unless TERMINAL-PATH.IO.e;
# Assigned rather than sunk: Proc throws from sink when stty fails, and a
# statement-level sink would happen outside try.
my $ = try shell 'stty -ixon -ixoff < /dev/tty 2>/dev/null';
Nil;
}
our sub terminal-resize-signals(--> List:D) is export {
PLATFORM-SPEC<resize-signals>.map({ Signal(Signal.enums{$_}) }).List;
}
our sub terminal-crash-signals(--> List:D) is export {
PLATFORM-SPEC<crash-signals>.map({ Signal(Signal.enums{$_}) }).List;
}