Skip to content

Commit 75a15a6

Browse files
committed
Telnet/SSHTerminal -> RemoteTerminal
1 parent 8c94ef3 commit 75a15a6

5 files changed

Lines changed: 94 additions & 139 deletions

File tree

gambaterm/remote_terminal.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""
2+
Provide a blessed Terminal subclass for remote (SSH/telnet) streams.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
import codecs
8+
import contextlib
9+
from typing import IO, Generator
10+
11+
from blessed import Terminal as BlessedTerminal
12+
from blessed.terminal import WINSZ
13+
14+
# Python's curses.setupterm() can only be called once per process — subsequent
15+
# calls with a different terminal type are silently ignored. Since the SSH
16+
# server handles multiple concurrent connections in threads, all RemoteTerminal
17+
# instances share whatever terminal type was initialized first by the local
18+
# Terminal(). We hardcode 'xterm-256color' as the kind since:
19+
# 1. It's universally compatible with modern terminals
20+
# 2. We use standard VT100/ANSI escape codes directly, not terminfo caps
21+
# 3. It avoids issues where the first client's TERM value differs from subsequent
22+
REMOTE_TERMINAL_TYPE = "xterm-256color"
23+
24+
25+
class RemoteTerminal(BlessedTerminal):
26+
"""A blessed Terminal subclass for remote streams (SSH, telnet).
27+
28+
Stubs raw/cbreak mode (the remote connection is already raw) and
29+
overrides size detection to use values provided by the server.
30+
"""
31+
32+
def __init__(
33+
self,
34+
stream: IO[str],
35+
keyboard_fd: int,
36+
rows: int,
37+
columns: int,
38+
) -> None:
39+
self._rows = rows
40+
self._columns = columns
41+
super().__init__(kind=REMOTE_TERMINAL_TYPE, stream=stream, force_styling=True)
42+
# Blessed only sets _keyboard_fd when stream is sys.__stdout__, so
43+
# for remote pipes we must set it and initialize the decoder manually
44+
self._keyboard_fd = keyboard_fd # type: ignore[assignment]
45+
self._keyboard_decoder = codecs.getincrementaldecoder("UTF-8")()
46+
47+
@property
48+
def is_a_tty(self) -> bool:
49+
return True
50+
51+
@contextlib.contextmanager
52+
def raw(self) -> Generator[None, None, None]:
53+
yield
54+
55+
@contextlib.contextmanager
56+
def cbreak(self) -> Generator[None, None, None]:
57+
yield
58+
59+
def _height_and_width(self) -> WINSZ:
60+
return WINSZ(
61+
ws_row=self._rows,
62+
ws_col=self._columns,
63+
ws_xpixel=0,
64+
ws_ypixel=0,
65+
)
66+
67+
def update_size(self, rows: int, columns: int) -> None:
68+
self._rows = rows
69+
self._columns = columns

gambaterm/ssh.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
from .main import add_base_arguments, add_optional_arguments, AppConfig
2828
from .console import Console, GameboyColor
2929

30-
from .ssh_app_session import SSHTerminal, process_to_terminal
30+
from .remote_terminal import RemoteTerminal
31+
from .ssh_app_session import process_to_terminal
3132

3233

3334
def is_x11_display_functional(
@@ -149,7 +150,7 @@ async def ssh_process_handler(process: SSHServerProcess[str]) -> int:
149150

150151

151152
def ssh_terminal_handler(
152-
term: SSHTerminal,
153+
term: RemoteTerminal,
153154
console_callback: Callable[[], Console],
154155
app_config: AppConfig,
155156
display: str | None,

gambaterm/ssh_app_session.py

Lines changed: 8 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,21 @@
11
"""
2-
Provide an async context manager to create a blessed SSHTerminal
2+
Provide an async context manager to create a blessed RemoteTerminal
33
from an AsyncSSH process.
44
"""
55
from __future__ import annotations
66

77
import os
8-
import codecs
98
import asyncio
10-
import contextlib
119
import subprocess
1210
from concurrent.futures import ThreadPoolExecutor
1311
from contextlib import asynccontextmanager, contextmanager
14-
from typing import AsyncIterator, Generator, IO, Iterator, TypeVar, Callable
15-
16-
from blessed import Terminal as BlessedTerminal
17-
from blessed.terminal import WINSZ
12+
from typing import AsyncIterator, Iterator, TypeVar, Callable
1813

1914
from asyncssh import SSHServerProcess
2015

21-
T = TypeVar("T")
22-
23-
24-
# Python's curses.setupterm() can only be called once per process — subsequent
25-
# calls with a different terminal type are silently ignored. Since the SSH
26-
# server handles multiple concurrent connections in threads, all SSHTerminal
27-
# instances share whatever terminal type was initialized first by the local
28-
# Terminal(). We hardcode 'xterm-256color' as the kind since:
29-
# 1. It's universally compatible with modern terminals
30-
# 2. We use standard VT100/ANSI escape codes directly, not terminfo caps
31-
# 3. It avoids issues where the first SSH client's TERM value differs
32-
SSH_TERMINAL_TYPE = "xterm-256color"
33-
34-
35-
class SSHTerminal(BlessedTerminal):
36-
"""A blessed Terminal subclass for SSH streams.
37-
38-
Following the pattern from x84 (x84/terminal.py), this stubs raw/cbreak
39-
mode (SSH is already raw) and overrides size detection to use values
40-
provided by the SSH server.
41-
"""
16+
from .remote_terminal import RemoteTerminal
4217

43-
def __init__(
44-
self,
45-
stream: IO[str],
46-
keyboard_fd: int,
47-
rows: int,
48-
columns: int,
49-
) -> None:
50-
self._rows = rows
51-
self._columns = columns
52-
super().__init__(kind=SSH_TERMINAL_TYPE, stream=stream, force_styling=True)
53-
# Blessed only sets _keyboard_fd when stream is sys.__stdout__, so
54-
# for SSH pipes we must set it and initialize the decoder manually
55-
self._keyboard_fd = keyboard_fd # type: ignore[assignment]
56-
self._keyboard_decoder = codecs.getincrementaldecoder("UTF-8")()
57-
58-
@property
59-
def is_a_tty(self) -> bool:
60-
return True
61-
62-
@contextlib.contextmanager
63-
def raw(self) -> Generator[None, None, None]:
64-
yield
65-
66-
@contextlib.contextmanager
67-
def cbreak(self) -> Generator[None, None, None]:
68-
yield
69-
70-
def _height_and_width(self) -> WINSZ:
71-
return WINSZ(
72-
ws_row=self._rows,
73-
ws_col=self._columns,
74-
ws_xpixel=0,
75-
ws_ypixel=0,
76-
)
77-
78-
def update_size(self, rows: int, columns: int) -> None:
79-
self._rows = rows
80-
self._columns = columns
18+
T = TypeVar("T")
8119

8220

8321
@asynccontextmanager
@@ -131,7 +69,7 @@ async def _input_pipe_from_process(
13169

13270
@contextmanager
13371
def _bind_resize(
134-
process: SSHServerProcess[str], ssh_term: SSHTerminal
72+
process: SSHServerProcess[str], ssh_term: RemoteTerminal
13573
) -> Iterator[None]:
13674
original_method = process.terminal_size_changed
13775

@@ -151,9 +89,9 @@ def terminal_size_changed(
15189
async def process_to_terminal(
15290
process: SSHServerProcess[str],
15391
executor: ThreadPoolExecutor,
154-
target: Callable[[SSHTerminal], T],
92+
target: Callable[[RemoteTerminal], T],
15593
) -> T:
156-
"""Create a blessed SSHTerminal from an SSH process.
94+
"""Create a blessed RemoteTerminal from an SSH process.
15795
15896
Once the redirections are set up, I/O become synchronous,
15997
so we run the target function in a thread executor to avoid blocking the event loop
@@ -164,7 +102,7 @@ async def process_to_terminal(
164102

165103
def _target() -> T:
166104
with open(write_fd, "w", newline="\r\n") as stream:
167-
ssh_term = SSHTerminal(
105+
ssh_term = RemoteTerminal(
168106
stream=stream,
169107
keyboard_fd=keyboard_fd,
170108
rows=height,

gambaterm/telnet.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@
2323
from .console import Console, InputGetter, GameboyColor
2424
from .keyboard_input import MESSAGE_SUGGESTING_KITTY_SUPPORT
2525
from .telnet_input import TelnetInputState, read_telnet_input
26+
from .remote_terminal import RemoteTerminal
2627
from .telnet_app_session import (
27-
TelnetTerminal,
2828
set_tcp_nodelay,
2929
telnet_to_terminal,
3030
)
@@ -62,13 +62,13 @@ def _save_dir_name(username: str | None) -> str:
6262

6363

6464
def thread_target(
65-
term: TelnetTerminal,
65+
term: RemoteTerminal,
6666
console_callback: Callable[[], Console],
6767
app_config: AppConfig,
6868
color_mode: ColorMode,
6969
input_state: TelnetInputState | None = None,
7070
) -> int:
71-
"""Run the emulator in a thread with the given TelnetTerminal."""
71+
"""Run the emulator in a thread with the given RemoteTerminal."""
7272
console: Console = console_callback()
7373

7474
if app_config.input_file is not None:
@@ -345,7 +345,7 @@ async def _telnet_shell(
345345
console_callback = console_cls.pop_console_arguments(namespace)
346346
config = AppConfig(**vars(namespace))
347347

348-
def target(term: TelnetTerminal) -> int:
348+
def target(term: RemoteTerminal) -> int:
349349
return thread_target(term, console_callback, config, color_mode, state)
350350

351351
return await telnet_to_terminal(

gambaterm/telnet_app_session.py

Lines changed: 10 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,21 @@
11
"""
2-
Provide a blessed TelnetTerminal and paced output forwarding
3-
for the telnet server.
2+
Provide paced output forwarding and terminal setup for the telnet server.
43
"""
5-
64
from __future__ import annotations
75

86
import os
97
import socket
10-
import codecs
118
import asyncio
12-
import contextlib
139
from contextlib import contextmanager
14-
from typing import IO, TYPE_CHECKING, Callable, Generator, Iterator, TypeVar
10+
from typing import TYPE_CHECKING, Callable, Iterator, TypeVar
1511
from concurrent.futures import ThreadPoolExecutor
1612

17-
from blessed import Terminal as BlessedTerminal
18-
from blessed.terminal import WINSZ
19-
2013
if TYPE_CHECKING:
2114
from telnetlib3.stream_writer import TelnetWriter
2215

23-
T = TypeVar("T")
24-
25-
# See ssh_app_session.py for rationale on hardcoding the terminal type.
26-
TELNET_TERMINAL_TYPE = "xterm-256color"
27-
28-
29-
class TelnetTerminal(BlessedTerminal):
30-
"""A blessed Terminal subclass for telnet streams.
31-
32-
Stubs raw/cbreak mode (telnet is already raw) and overrides size
33-
detection to use values from NAWS negotiation.
34-
"""
35-
36-
def __init__(
37-
self,
38-
stream: IO[str],
39-
keyboard_fd: int,
40-
rows: int,
41-
columns: int,
42-
) -> None:
43-
self._rows = rows
44-
self._columns = columns
45-
super().__init__(kind=TELNET_TERMINAL_TYPE, stream=stream, force_styling=True)
46-
self._keyboard_fd = keyboard_fd # type: ignore[assignment]
47-
self._keyboard_decoder = codecs.getincrementaldecoder("UTF-8")()
48-
49-
@property
50-
def is_a_tty(self) -> bool:
51-
return True
52-
53-
@contextlib.contextmanager
54-
def raw(self) -> Generator[None, None, None]:
55-
yield
16+
from .remote_terminal import RemoteTerminal
5617

57-
@contextlib.contextmanager
58-
def cbreak(self) -> Generator[None, None, None]:
59-
yield
60-
61-
def _height_and_width(self) -> WINSZ:
62-
return WINSZ(
63-
ws_row=self._rows,
64-
ws_col=self._columns,
65-
ws_xpixel=0,
66-
ws_ypixel=0,
67-
)
68-
69-
def update_size(self, rows: int, columns: int) -> None:
70-
self._rows = rows
71-
self._columns = columns
18+
T = TypeVar("T")
7219

7320

7421
def set_tcp_nodelay(writer: TelnetWriter) -> None:
@@ -136,9 +83,9 @@ async def paced_forward_output(
13683
@contextmanager
13784
def bind_resize_telnet(
13885
writer: TelnetWriter,
139-
term: TelnetTerminal,
86+
term: RemoteTerminal,
14087
) -> Iterator[None]:
141-
"""Hook telnetlib3 NAWS callbacks to update TelnetTerminal size.
88+
"""Hook telnetlib3 NAWS callbacks to update RemoteTerminal size.
14289
14390
telnetlib3 dispatches NAWS via a callback registry on the writer
14491
(``_ext_callback[NAWS]``), not through ``protocol.on_naws`` directly.
@@ -162,16 +109,16 @@ def on_naws(rows: int, cols: int) -> None:
162109
async def telnet_to_terminal(
163110
writer: TelnetWriter,
164111
executor: ThreadPoolExecutor,
165-
target: Callable[[TelnetTerminal], T],
112+
target: Callable[[RemoteTerminal], T],
166113
input_read_fd: int,
167114
) -> T:
168-
"""Create a TelnetTerminal and run *target* in a thread executor.
115+
"""Create a RemoteTerminal and run *target* in a thread executor.
169116
170117
Sets up a pipe for output forwarding with paced delivery at 60 pps.
171118
172119
:param writer: telnetlib3 writer
173120
:param executor: ThreadPoolExecutor for running the game thread
174-
:param target: callable receiving the TelnetTerminal, run in executor
121+
:param target: callable receiving the RemoteTerminal, run in executor
175122
:param input_read_fd: read end of the input pipe (keyboard_fd for blessed)
176123
:returns: return value of *target*
177124
"""
@@ -183,7 +130,7 @@ async def telnet_to_terminal(
183130

184131
def _target() -> T:
185132
with open(write_fd, "w", newline="\r\n") as stream:
186-
telnet_term = TelnetTerminal(
133+
telnet_term = RemoteTerminal(
187134
stream=stream,
188135
keyboard_fd=input_read_fd,
189136
rows=rows,

0 commit comments

Comments
 (0)