terminux 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- terminux/__init__.py +5 -0
- terminux/__main__.py +8 -0
- terminux/app.py +123 -0
- terminux/core/__init__.py +3 -0
- terminux/core/model.py +291 -0
- terminux/core/persistence.py +58 -0
- terminux/core/pty_backend.py +137 -0
- terminux/core/shellprobe.py +33 -0
- terminux/core/terminal.py +144 -0
- terminux/server/__init__.py +3 -0
- terminux/server/asgi.py +324 -0
- terminux/server/auth.py +16 -0
- terminux/web/index.html +134 -0
- terminux/web/main.js +493 -0
- terminux/web/vendor/addon-fit.js +2 -0
- terminux/web/vendor/xterm.css +218 -0
- terminux/web/vendor/xterm.js +2 -0
- terminux-0.1.0.dist-info/METADATA +11 -0
- terminux-0.1.0.dist-info/RECORD +21 -0
- terminux-0.1.0.dist-info/WHEEL +4 -0
- terminux-0.1.0.dist-info/entry_points.txt +2 -0
terminux/__init__.py
ADDED
terminux/__main__.py
ADDED
terminux/app.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Entrypoint: run the loopback server, then host it in a pywebview window.
|
|
2
|
+
|
|
3
|
+
``--no-window`` runs the server headless (browse to the printed URL) — used
|
|
4
|
+
for development and e2e tests, and a preview of the future "web mode".
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import contextlib
|
|
11
|
+
import logging
|
|
12
|
+
import socket
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from typing import cast
|
|
16
|
+
|
|
17
|
+
import uvicorn
|
|
18
|
+
|
|
19
|
+
from terminux.server.asgi import AppController, build_app
|
|
20
|
+
from terminux.server.auth import SESSION_TOKEN
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger(__name__)
|
|
23
|
+
HOST = "127.0.0.1"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _free_port() -> int:
|
|
27
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
28
|
+
s.bind((HOST, 0))
|
|
29
|
+
return int(s.getsockname()[1])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _serve(server: uvicorn.Server) -> None:
|
|
33
|
+
server.run()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main() -> None:
|
|
37
|
+
logging.basicConfig(
|
|
38
|
+
level=logging.INFO, format="%(levelname)s %(name)s: %(message)s"
|
|
39
|
+
)
|
|
40
|
+
parser = argparse.ArgumentParser(prog="terminux")
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--no-window",
|
|
43
|
+
action="store_true",
|
|
44
|
+
help="run the server only (no desktop window)",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument("--port", type=int, default=0, help="bind port (0 = ephemeral)")
|
|
47
|
+
args = parser.parse_args()
|
|
48
|
+
|
|
49
|
+
port = args.port or _free_port()
|
|
50
|
+
app = build_app(persist=True)
|
|
51
|
+
config = uvicorn.Config(
|
|
52
|
+
app,
|
|
53
|
+
host=HOST,
|
|
54
|
+
port=port,
|
|
55
|
+
log_level="warning",
|
|
56
|
+
ws="websockets-sansio", # avoid the deprecated legacy websockets impl
|
|
57
|
+
)
|
|
58
|
+
server = uvicorn.Server(config)
|
|
59
|
+
|
|
60
|
+
thread = threading.Thread(target=_serve, args=(server,), daemon=True)
|
|
61
|
+
thread.start()
|
|
62
|
+
while not server.started:
|
|
63
|
+
time.sleep(0.02)
|
|
64
|
+
|
|
65
|
+
url = f"http://{HOST}:{port}/?t={SESSION_TOKEN}"
|
|
66
|
+
log.info("terminux server ready at %s", url)
|
|
67
|
+
|
|
68
|
+
if args.no_window:
|
|
69
|
+
with contextlib.suppress(KeyboardInterrupt):
|
|
70
|
+
thread.join()
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
_run_windowed(url, app.state.controller, server)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _run_windowed(url: str, ctl: AppController, server: uvicorn.Server) -> None:
|
|
77
|
+
import webview # noqa: PLC0415 (heavy GUI import; only when windowing)
|
|
78
|
+
from webview.dom import DOMEventHandler # noqa: PLC0415
|
|
79
|
+
|
|
80
|
+
window = webview.create_window("terminux", url, width=1100, height=720)
|
|
81
|
+
if window is None:
|
|
82
|
+
msg = "failed to create application window"
|
|
83
|
+
raise RuntimeError(msg)
|
|
84
|
+
|
|
85
|
+
def _on_drop(event: dict[str, object]) -> None:
|
|
86
|
+
# WKWebView hides file paths from JS; pywebview injects the real path
|
|
87
|
+
# as ``pywebviewFullPath`` only for a Python drop handler (registering
|
|
88
|
+
# one also enables the native Cocoa path capture). Route them to the
|
|
89
|
+
# active terminal, shell-quoted — drop a file into Claude Code.
|
|
90
|
+
# ``event`` is pywebview's loosely-typed JSON event dict.
|
|
91
|
+
if not isinstance(event, dict):
|
|
92
|
+
return
|
|
93
|
+
transfer = event.get("dataTransfer")
|
|
94
|
+
files = (
|
|
95
|
+
cast("dict[str, object]", transfer).get("files")
|
|
96
|
+
if isinstance(transfer, dict)
|
|
97
|
+
else None
|
|
98
|
+
)
|
|
99
|
+
paths: list[str] = []
|
|
100
|
+
for f in files if isinstance(files, list) else []:
|
|
101
|
+
if isinstance(f, dict):
|
|
102
|
+
p = cast("dict[str, object]", f).get("pywebviewFullPath")
|
|
103
|
+
if isinstance(p, str):
|
|
104
|
+
paths.append(p)
|
|
105
|
+
if paths:
|
|
106
|
+
ctl.paste_paths(paths)
|
|
107
|
+
|
|
108
|
+
def _register_drop() -> None:
|
|
109
|
+
# Must run after the DOM is loaded; prevent_default stops the webview
|
|
110
|
+
# from navigating to the dropped file.
|
|
111
|
+
window.dom.document.on("drop", DOMEventHandler(_on_drop, prevent_default=True))
|
|
112
|
+
|
|
113
|
+
def _shutdown() -> None:
|
|
114
|
+
ctl.terminals.close_all()
|
|
115
|
+
server.should_exit = True
|
|
116
|
+
|
|
117
|
+
window.events.loaded += _register_drop
|
|
118
|
+
window.events.closed += _shutdown
|
|
119
|
+
webview.start()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
main()
|
terminux/core/model.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""In-memory authoritative app state: workspaces and tabs.
|
|
2
|
+
|
|
3
|
+
Only structure (ids, names, order, active selection, UI prefs) is persisted.
|
|
4
|
+
Live terminals are transient and rebuilt on demand; see ``core.terminal``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _new_id() -> str:
|
|
16
|
+
return uuid.uuid4().hex
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WorkspaceStatus(str, Enum):
|
|
20
|
+
"""Auto-derived sidebar status (functional spec §4.4)."""
|
|
21
|
+
|
|
22
|
+
ACTIVE = "active"
|
|
23
|
+
RUNNING = "running"
|
|
24
|
+
IDLE = "idle"
|
|
25
|
+
EXITED = "exited"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Tab:
|
|
30
|
+
"""One terminal session inside a workspace."""
|
|
31
|
+
|
|
32
|
+
id: str = field(default_factory=_new_id)
|
|
33
|
+
title: str = "shell"
|
|
34
|
+
user_set_title: bool = False
|
|
35
|
+
# Transient (never persisted):
|
|
36
|
+
terminal_id: str | None = None
|
|
37
|
+
has_unseen_output: bool = False
|
|
38
|
+
spawn_cwd: str | None = None # directory inherited from the previous tab
|
|
39
|
+
|
|
40
|
+
def to_json(self) -> dict[str, Any]:
|
|
41
|
+
return {
|
|
42
|
+
"id": self.id,
|
|
43
|
+
"title": self.title,
|
|
44
|
+
"user_set_title": self.user_set_title,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_json(cls, data: dict[str, Any]) -> Tab:
|
|
49
|
+
return cls(
|
|
50
|
+
id=str(data["id"]),
|
|
51
|
+
title=str(data.get("title", "shell")),
|
|
52
|
+
user_set_title=bool(data.get("user_set_title")),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Workspace:
|
|
58
|
+
"""A named container for a set of terminal tabs."""
|
|
59
|
+
|
|
60
|
+
id: str = field(default_factory=_new_id)
|
|
61
|
+
name: str = "workspace"
|
|
62
|
+
tab_ids: list[str] = field(default_factory=list)
|
|
63
|
+
active_tab_id: str | None = None
|
|
64
|
+
# Transient: set when a tab produces output while this workspace is not active.
|
|
65
|
+
has_unseen_output: bool = False
|
|
66
|
+
|
|
67
|
+
def to_json(self) -> dict[str, Any]:
|
|
68
|
+
return {
|
|
69
|
+
"id": self.id,
|
|
70
|
+
"name": self.name,
|
|
71
|
+
"tab_ids": list(self.tab_ids),
|
|
72
|
+
"active_tab_id": self.active_tab_id,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_json(cls, data: dict[str, Any]) -> Workspace:
|
|
77
|
+
return cls(
|
|
78
|
+
id=str(data["id"]),
|
|
79
|
+
name=str(data.get("name", "workspace")),
|
|
80
|
+
tab_ids=[str(t) for t in data.get("tab_ids", [])],
|
|
81
|
+
active_tab_id=(
|
|
82
|
+
str(data["active_tab_id"])
|
|
83
|
+
if data.get("active_tab_id") is not None
|
|
84
|
+
else None
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class UiPrefs:
|
|
91
|
+
"""Persisted UI preferences."""
|
|
92
|
+
|
|
93
|
+
sidebar_width: int = 220
|
|
94
|
+
sidebar_collapsed: bool = False
|
|
95
|
+
font_size: int = 13
|
|
96
|
+
|
|
97
|
+
def to_json(self) -> dict[str, Any]:
|
|
98
|
+
return {
|
|
99
|
+
"sidebar_width": self.sidebar_width,
|
|
100
|
+
"sidebar_collapsed": self.sidebar_collapsed,
|
|
101
|
+
"font_size": self.font_size,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_json(cls, data: dict[str, Any]) -> UiPrefs:
|
|
106
|
+
return cls(
|
|
107
|
+
sidebar_width=int(data.get("sidebar_width", 220)),
|
|
108
|
+
sidebar_collapsed=bool(data.get("sidebar_collapsed")),
|
|
109
|
+
font_size=int(data.get("font_size", 13)),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
SCHEMA_VERSION = 1
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class AppState:
|
|
118
|
+
"""Authoritative state. All mutation happens on the backend event loop."""
|
|
119
|
+
|
|
120
|
+
workspaces: list[Workspace] = field(default_factory=list)
|
|
121
|
+
tabs: dict[str, Tab] = field(default_factory=dict)
|
|
122
|
+
active_workspace_id: str | None = None
|
|
123
|
+
ui: UiPrefs = field(default_factory=UiPrefs)
|
|
124
|
+
|
|
125
|
+
# ----- construction -------------------------------------------------
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def default(cls) -> AppState:
|
|
129
|
+
"""A single workspace with one tab — the always-valid baseline."""
|
|
130
|
+
state = cls()
|
|
131
|
+
ws = state.add_workspace(name="workspace 1")
|
|
132
|
+
state.add_tab(ws.id)
|
|
133
|
+
state.active_workspace_id = ws.id
|
|
134
|
+
return state
|
|
135
|
+
|
|
136
|
+
# ----- workspace ops ------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def add_workspace(self, name: str | None = None) -> Workspace:
|
|
139
|
+
ws = Workspace(name=name or self._next_workspace_name())
|
|
140
|
+
self.workspaces.append(ws)
|
|
141
|
+
return ws
|
|
142
|
+
|
|
143
|
+
def _next_workspace_name(self) -> str:
|
|
144
|
+
used = {w.name for w in self.workspaces}
|
|
145
|
+
i = 1
|
|
146
|
+
while f"workspace {i}" in used:
|
|
147
|
+
i += 1
|
|
148
|
+
return f"workspace {i}"
|
|
149
|
+
|
|
150
|
+
def get_workspace(self, ws_id: str) -> Workspace | None:
|
|
151
|
+
return next((w for w in self.workspaces if w.id == ws_id), None)
|
|
152
|
+
|
|
153
|
+
def remove_workspace(self, ws_id: str) -> list[str]:
|
|
154
|
+
"""Remove a workspace; return the ids of its tabs (caller kills terminals)."""
|
|
155
|
+
ws = self.get_workspace(ws_id)
|
|
156
|
+
if ws is None:
|
|
157
|
+
return []
|
|
158
|
+
tab_ids = list(ws.tab_ids)
|
|
159
|
+
for tid in tab_ids:
|
|
160
|
+
self.tabs.pop(tid, None)
|
|
161
|
+
self.workspaces = [w for w in self.workspaces if w.id != ws_id]
|
|
162
|
+
if self.active_workspace_id == ws_id:
|
|
163
|
+
self.active_workspace_id = (
|
|
164
|
+
self.workspaces[0].id if self.workspaces else None
|
|
165
|
+
)
|
|
166
|
+
return tab_ids
|
|
167
|
+
|
|
168
|
+
def set_active_workspace(self, ws_id: str) -> None:
|
|
169
|
+
if self.get_workspace(ws_id) is None:
|
|
170
|
+
return
|
|
171
|
+
self.active_workspace_id = ws_id
|
|
172
|
+
ws = self.get_workspace(ws_id)
|
|
173
|
+
if ws is not None:
|
|
174
|
+
ws.has_unseen_output = False
|
|
175
|
+
for tid in ws.tab_ids:
|
|
176
|
+
tab = self.tabs.get(tid)
|
|
177
|
+
if tab is not None and tid == ws.active_tab_id:
|
|
178
|
+
tab.has_unseen_output = False
|
|
179
|
+
|
|
180
|
+
# ----- tab ops ------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def add_tab(
|
|
183
|
+
self,
|
|
184
|
+
ws_id: str,
|
|
185
|
+
title: str = "shell",
|
|
186
|
+
spawn_cwd: str | None = None,
|
|
187
|
+
) -> Tab | None:
|
|
188
|
+
ws = self.get_workspace(ws_id)
|
|
189
|
+
if ws is None:
|
|
190
|
+
return None
|
|
191
|
+
tab = Tab(title=title, spawn_cwd=spawn_cwd)
|
|
192
|
+
self.tabs[tab.id] = tab
|
|
193
|
+
ws.tab_ids.append(tab.id)
|
|
194
|
+
ws.active_tab_id = tab.id
|
|
195
|
+
return tab
|
|
196
|
+
|
|
197
|
+
def remove_tab(self, tab_id: str) -> None:
|
|
198
|
+
for ws in self.workspaces:
|
|
199
|
+
if tab_id in ws.tab_ids:
|
|
200
|
+
ws.tab_ids.remove(tab_id)
|
|
201
|
+
if ws.active_tab_id == tab_id:
|
|
202
|
+
ws.active_tab_id = ws.tab_ids[-1] if ws.tab_ids else None
|
|
203
|
+
self.tabs.pop(tab_id, None)
|
|
204
|
+
|
|
205
|
+
# ----- status -------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def workspace_status(self, ws_id: str) -> WorkspaceStatus:
|
|
208
|
+
ws = self.get_workspace(ws_id)
|
|
209
|
+
if ws is None:
|
|
210
|
+
return WorkspaceStatus.IDLE
|
|
211
|
+
if ws_id == self.active_workspace_id:
|
|
212
|
+
return WorkspaceStatus.ACTIVE
|
|
213
|
+
has_live = any(
|
|
214
|
+
(t := self.tabs.get(tid)) is not None and t.terminal_id is not None
|
|
215
|
+
for tid in ws.tab_ids
|
|
216
|
+
)
|
|
217
|
+
if not has_live and ws.tab_ids:
|
|
218
|
+
return WorkspaceStatus.EXITED
|
|
219
|
+
if ws.has_unseen_output:
|
|
220
|
+
return WorkspaceStatus.RUNNING
|
|
221
|
+
return WorkspaceStatus.IDLE
|
|
222
|
+
|
|
223
|
+
# ----- serialization ------------------------------------------------
|
|
224
|
+
|
|
225
|
+
def to_json(self) -> dict[str, Any]:
|
|
226
|
+
return {
|
|
227
|
+
"version": SCHEMA_VERSION,
|
|
228
|
+
"workspaces": [w.to_json() for w in self.workspaces],
|
|
229
|
+
"tabs": [t.to_json() for t in self.tabs.values()],
|
|
230
|
+
"active_workspace_id": self.active_workspace_id,
|
|
231
|
+
"ui": self.ui.to_json(),
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
@classmethod
|
|
235
|
+
def from_json(cls, data: dict[str, Any]) -> AppState:
|
|
236
|
+
tabs = {}
|
|
237
|
+
for raw in data.get("tabs", []):
|
|
238
|
+
tab = Tab.from_json(raw)
|
|
239
|
+
tabs[tab.id] = tab
|
|
240
|
+
workspaces = [Workspace.from_json(w) for w in data.get("workspaces", [])]
|
|
241
|
+
state = cls(
|
|
242
|
+
workspaces=workspaces,
|
|
243
|
+
tabs=tabs,
|
|
244
|
+
active_workspace_id=data.get("active_workspace_id"),
|
|
245
|
+
ui=UiPrefs.from_json(data.get("ui", {})),
|
|
246
|
+
)
|
|
247
|
+
state._repair()
|
|
248
|
+
return state
|
|
249
|
+
|
|
250
|
+
def _repair(self) -> AppState:
|
|
251
|
+
"""Drop dangling references; guarantee >= 1 workspace with >= 1 tab."""
|
|
252
|
+
for ws in self.workspaces:
|
|
253
|
+
ws.tab_ids = [tid for tid in ws.tab_ids if tid in self.tabs]
|
|
254
|
+
if ws.active_tab_id not in ws.tab_ids:
|
|
255
|
+
ws.active_tab_id = ws.tab_ids[-1] if ws.tab_ids else None
|
|
256
|
+
if not ws.tab_ids:
|
|
257
|
+
tab = Tab()
|
|
258
|
+
self.tabs[tab.id] = tab
|
|
259
|
+
ws.tab_ids.append(tab.id)
|
|
260
|
+
ws.active_tab_id = tab.id
|
|
261
|
+
# drop orphan tabs not referenced by any workspace
|
|
262
|
+
referenced = {tid for ws in self.workspaces for tid in ws.tab_ids}
|
|
263
|
+
self.tabs = {tid: t for tid, t in self.tabs.items() if tid in referenced}
|
|
264
|
+
if not self.workspaces:
|
|
265
|
+
ws = self.add_workspace(name="workspace 1")
|
|
266
|
+
self.add_tab(ws.id)
|
|
267
|
+
if self.get_workspace(self.active_workspace_id or "") is None:
|
|
268
|
+
self.active_workspace_id = self.workspaces[0].id
|
|
269
|
+
return self
|
|
270
|
+
|
|
271
|
+
def view_json(self) -> dict[str, Any]:
|
|
272
|
+
"""Snapshot for the frontend, including derived status."""
|
|
273
|
+
return {
|
|
274
|
+
"workspaces": [
|
|
275
|
+
{
|
|
276
|
+
**w.to_json(),
|
|
277
|
+
"status": self.workspace_status(w.id).value,
|
|
278
|
+
}
|
|
279
|
+
for w in self.workspaces
|
|
280
|
+
],
|
|
281
|
+
"tabs": {
|
|
282
|
+
tid: {
|
|
283
|
+
**t.to_json(),
|
|
284
|
+
"live": t.terminal_id is not None,
|
|
285
|
+
"has_unseen_output": t.has_unseen_output,
|
|
286
|
+
}
|
|
287
|
+
for tid, t in self.tabs.items()
|
|
288
|
+
},
|
|
289
|
+
"active_workspace_id": self.active_workspace_id,
|
|
290
|
+
"ui": self.ui.to_json(),
|
|
291
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Atomic, versioned JSON persistence of structural state.
|
|
2
|
+
|
|
3
|
+
Live processes and scrollback are never persisted (functional spec §7):
|
|
4
|
+
a restored tab starts a fresh shell.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import tempfile
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import platformdirs
|
|
17
|
+
|
|
18
|
+
from terminux.core.model import AppState
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def state_path() -> Path:
|
|
24
|
+
data_dir = Path(platformdirs.user_data_dir("terminux"))
|
|
25
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
return data_dir / "state.json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_state(path: Path | None = None) -> AppState:
|
|
30
|
+
"""Load persisted state, falling back to a sane default on any error."""
|
|
31
|
+
path = path or state_path()
|
|
32
|
+
try:
|
|
33
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
34
|
+
return AppState.from_json(raw)
|
|
35
|
+
except FileNotFoundError:
|
|
36
|
+
log.info("no persisted state at %s; starting fresh", path)
|
|
37
|
+
except (json.JSONDecodeError, KeyError, TypeError, ValueError, OSError) as exc:
|
|
38
|
+
log.warning("could not load state from %s (%s); starting fresh", path, exc)
|
|
39
|
+
return AppState.default()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def save_state(state: AppState, path: Path | None = None) -> None:
|
|
43
|
+
"""Write state atomically (temp file + os.replace)."""
|
|
44
|
+
path = path or state_path()
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
payload = json.dumps(state.to_json(), indent=2)
|
|
47
|
+
fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=".state-", suffix=".tmp")
|
|
48
|
+
tmp_path = Path(tmp_name)
|
|
49
|
+
try:
|
|
50
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
51
|
+
fh.write(payload)
|
|
52
|
+
fh.flush()
|
|
53
|
+
os.fsync(fh.fileno())
|
|
54
|
+
tmp_path.replace(path)
|
|
55
|
+
except OSError:
|
|
56
|
+
log.exception("failed to persist state to %s", path)
|
|
57
|
+
with contextlib.suppress(OSError):
|
|
58
|
+
tmp_path.unlink(missing_ok=True)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""PTY backend abstraction.
|
|
2
|
+
|
|
3
|
+
Unix is implemented via ``ptyprocess``. Windows (``pywinpty``/ConPTY) is a
|
|
4
|
+
future addition behind this same Protocol — see technical spec §10.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import signal
|
|
13
|
+
import subprocess # noqa: S404 (fixed argv to `lsof`, no shell, resolved path)
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Protocol, cast, runtime_checkable
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class PtyBackend(Protocol):
|
|
21
|
+
"""OS-agnostic PTY interface used by ``core.terminal``."""
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def fd(self) -> int: ...
|
|
25
|
+
|
|
26
|
+
def write(self, data: bytes) -> None: ...
|
|
27
|
+
|
|
28
|
+
def resize(self, cols: int, rows: int) -> None: ...
|
|
29
|
+
|
|
30
|
+
def is_alive(self) -> bool: ...
|
|
31
|
+
|
|
32
|
+
def terminate(self) -> None: ...
|
|
33
|
+
|
|
34
|
+
def exit_code(self) -> int | None: ...
|
|
35
|
+
|
|
36
|
+
def cwd(self) -> str | None: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class UnixPty:
|
|
40
|
+
"""PTY-backed child process for POSIX systems."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, argv: list[str], cwd: str, cols: int, rows: int) -> None:
|
|
43
|
+
from ptyprocess import PtyProcess # noqa: PLC0415 (optional/heavy import)
|
|
44
|
+
|
|
45
|
+
env = dict(os.environ)
|
|
46
|
+
env.setdefault("TERM", "xterm-256color")
|
|
47
|
+
env["TERMINUX"] = "1"
|
|
48
|
+
self._proc = PtyProcess.spawn(
|
|
49
|
+
argv,
|
|
50
|
+
cwd=cwd,
|
|
51
|
+
env=env,
|
|
52
|
+
dimensions=(rows, cols),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def fd(self) -> int:
|
|
57
|
+
return int(self._proc.fd)
|
|
58
|
+
|
|
59
|
+
def write(self, data: bytes) -> None:
|
|
60
|
+
with contextlib.suppress(OSError):
|
|
61
|
+
os.write(self._proc.fd, data)
|
|
62
|
+
|
|
63
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
64
|
+
with contextlib.suppress(OSError, ValueError):
|
|
65
|
+
self._proc.setwinsize(rows, cols)
|
|
66
|
+
|
|
67
|
+
def is_alive(self) -> bool:
|
|
68
|
+
return bool(self._proc.isalive())
|
|
69
|
+
|
|
70
|
+
def terminate(self) -> None:
|
|
71
|
+
"""SIGHUP → SIGTERM → SIGKILL escalation (functional spec §10)."""
|
|
72
|
+
if not self._proc.isalive():
|
|
73
|
+
return
|
|
74
|
+
for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGKILL):
|
|
75
|
+
try:
|
|
76
|
+
self._proc.kill(sig)
|
|
77
|
+
except OSError:
|
|
78
|
+
return
|
|
79
|
+
if not self._proc.isalive():
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
def exit_code(self) -> int | None:
|
|
83
|
+
if self._proc.isalive():
|
|
84
|
+
return None
|
|
85
|
+
try:
|
|
86
|
+
self._proc.wait()
|
|
87
|
+
except Exception: # noqa: BLE001 (best-effort reap)
|
|
88
|
+
return None
|
|
89
|
+
return cast("int | None", self._proc.exitstatus)
|
|
90
|
+
|
|
91
|
+
def cwd(self) -> str | None:
|
|
92
|
+
"""Best-effort current directory of the shell process.
|
|
93
|
+
|
|
94
|
+
Lets a new tab open where the previously active shell was (cmux
|
|
95
|
+
behaviour), without requiring shell-side OSC 7 configuration.
|
|
96
|
+
"""
|
|
97
|
+
pid = self._proc.pid
|
|
98
|
+
if pid is None:
|
|
99
|
+
return None
|
|
100
|
+
if sys.platform == "linux":
|
|
101
|
+
return _linux_cwd(pid)
|
|
102
|
+
if sys.platform == "darwin":
|
|
103
|
+
return _darwin_cwd(pid)
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _linux_cwd(pid: int) -> str | None:
|
|
108
|
+
with contextlib.suppress(OSError):
|
|
109
|
+
return str(Path(f"/proc/{pid}/cwd").readlink())
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _darwin_cwd(pid: int) -> str | None:
|
|
114
|
+
lsof = shutil.which("lsof")
|
|
115
|
+
if lsof is None:
|
|
116
|
+
return None
|
|
117
|
+
try:
|
|
118
|
+
result = subprocess.run( # noqa: S603 (fixed argv, resolved path, no shell)
|
|
119
|
+
[lsof, "-a", "-d", "cwd", "-p", str(pid), "-Fn"],
|
|
120
|
+
capture_output=True,
|
|
121
|
+
text=True,
|
|
122
|
+
timeout=2,
|
|
123
|
+
check=False,
|
|
124
|
+
)
|
|
125
|
+
except (OSError, subprocess.SubprocessError):
|
|
126
|
+
return None
|
|
127
|
+
for line in result.stdout.splitlines():
|
|
128
|
+
if line.startswith("n"):
|
|
129
|
+
return line[1:]
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def spawn_pty(argv: list[str], cwd: str, cols: int, rows: int) -> PtyBackend:
|
|
134
|
+
if sys.platform == "win32":
|
|
135
|
+
msg = "Windows PTY backend is not implemented yet (technical spec §10)."
|
|
136
|
+
raise NotImplementedError(msg)
|
|
137
|
+
return UnixPty(argv, cwd, cols, rows)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Resolve the default shell and starting directory per OS."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def default_shell() -> list[str]:
|
|
12
|
+
"""Return the argv for a fresh interactive login shell."""
|
|
13
|
+
if sys.platform == "win32":
|
|
14
|
+
for candidate in ("pwsh.exe", "powershell.exe", "cmd.exe"):
|
|
15
|
+
found = shutil.which(candidate)
|
|
16
|
+
if found:
|
|
17
|
+
return [found]
|
|
18
|
+
return ["cmd.exe"]
|
|
19
|
+
shell = os.environ.get("SHELL")
|
|
20
|
+
if shell and Path(shell).exists():
|
|
21
|
+
return [shell, "-l"]
|
|
22
|
+
for candidate in ("/bin/zsh", "/bin/bash", "/bin/sh"):
|
|
23
|
+
if Path(candidate).exists():
|
|
24
|
+
return [candidate, "-l"]
|
|
25
|
+
return ["/bin/sh"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def default_cwd() -> str:
|
|
29
|
+
"""Starting directory for a new terminal (home, with cwd fallback)."""
|
|
30
|
+
home = Path.home()
|
|
31
|
+
if home.is_dir():
|
|
32
|
+
return str(home)
|
|
33
|
+
return str(Path.cwd())
|