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;
}