elsewindow 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.
elsewindow/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ # Copyright (c) 2026 kogeler
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Xpra application runner over one owned SSH master."""
5
+
6
+ from __future__ import annotations
7
+
8
+
9
+ def _resolve_version() -> str:
10
+ """Resolve the source-tree or installed distribution version."""
11
+ try:
12
+ from pathlib import Path
13
+
14
+ root = Path(__file__).resolve().parent.parent
15
+ if (root / "pyproject.toml").is_file():
16
+ return (root / ".version").read_text(encoding="utf-8").strip()
17
+ bundled = Path(__file__).resolve().parent / ".version"
18
+ if bundled.is_file():
19
+ return bundled.read_text(encoding="utf-8").strip()
20
+ except OSError:
21
+ pass
22
+
23
+ try:
24
+ from importlib.metadata import PackageNotFoundError, version
25
+
26
+ return version("elsewindow")
27
+ except PackageNotFoundError:
28
+ return "0.0.0+unknown"
29
+
30
+
31
+ __version__ = _resolve_version()
32
+
33
+ __all__ = ["__version__"]
elsewindow/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ # Copyright (c) 2026 kogeler
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Module entry point for Elsewindow."""
5
+
6
+ from .cli import main
7
+
8
+ raise SystemExit(main())
elsewindow/cli.py ADDED
@@ -0,0 +1,204 @@
1
+ # Copyright (c) 2026 kogeler
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Command-line interface for one owned remote Xpra application."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import asyncio
10
+ import hashlib
11
+ import shutil
12
+ import signal
13
+ import sys
14
+ from collections.abc import Sequence
15
+ from contextlib import suppress
16
+ from importlib.metadata import PackageNotFoundError
17
+ from importlib.metadata import version as distribution_version
18
+
19
+ from ssh_wrapper.errors import SSHError
20
+
21
+ from . import __version__
22
+ from .config import (
23
+ DEFAULT_CONNECT_TIMEOUT,
24
+ DEFAULT_ENCODING_PROFILE,
25
+ DEFAULT_GRACE_TIMEOUT,
26
+ DEFAULT_HEARTBEAT_INTERVAL,
27
+ DEFAULT_LEASE_TIMEOUT,
28
+ DEFAULT_NETWORK_PROFILE,
29
+ DEFAULT_POLL_INTERVAL,
30
+ DEFAULT_PROBE_TIMEOUT,
31
+ DEFAULT_READY_TIMEOUT,
32
+ SUPPORTED_ENCODING_PROFILES,
33
+ SUPPORTED_NETWORK_PROFILES,
34
+ XpraConfig,
35
+ )
36
+ from .session import XpraSession
37
+
38
+
39
+ def build_parser() -> argparse.ArgumentParser:
40
+ """Build the public command-line contract."""
41
+ parser = argparse.ArgumentParser(
42
+ prog="elsewindow",
43
+ description="Run one new remote GUI application through Xpra over owned SSH.",
44
+ )
45
+ parser.add_argument(
46
+ "--version", action="version", version=f"%(prog)s {__version__}"
47
+ )
48
+ parser.add_argument(
49
+ "--diagnose",
50
+ action="store_true",
51
+ help="report bundled versions, profile digests, and local prerequisites",
52
+ )
53
+ authority = parser.add_mutually_exclusive_group()
54
+ authority.add_argument("--ssh-alias", help="trusted OpenSSH host alias")
55
+ authority.add_argument("--host", help="direct SSH host or address")
56
+ parser.add_argument("--user", help="remote user required with --host")
57
+ parser.add_argument("--port", type=int, default=22, help="direct SSH port")
58
+ parser.add_argument(
59
+ "--encoding-profile",
60
+ choices=SUPPORTED_ENCODING_PROFILES,
61
+ default=DEFAULT_ENCODING_PROFILE,
62
+ help=(
63
+ "reviewed pixel transport profile; h264 uses native VA-API, "
64
+ "libyuv, OpenGL, and alpha-capable fallback"
65
+ ),
66
+ )
67
+ parser.add_argument(
68
+ "--network-profile",
69
+ choices=SUPPORTED_NETWORK_PROFILES,
70
+ default=DEFAULT_NETWORK_PROFILE,
71
+ help="reviewed client quality/network profile (default: %(default)s)",
72
+ )
73
+ parser.add_argument(
74
+ "--connect-timeout", type=float, default=DEFAULT_CONNECT_TIMEOUT
75
+ )
76
+ parser.add_argument("--ready-timeout", type=float, default=DEFAULT_READY_TIMEOUT)
77
+ parser.add_argument("--probe-timeout", type=float, default=DEFAULT_PROBE_TIMEOUT)
78
+ parser.add_argument("--poll-interval", type=float, default=DEFAULT_POLL_INTERVAL)
79
+ parser.add_argument(
80
+ "--heartbeat-interval", type=float, default=DEFAULT_HEARTBEAT_INTERVAL
81
+ )
82
+ parser.add_argument("--lease-timeout", type=float, default=DEFAULT_LEASE_TIMEOUT)
83
+ parser.add_argument("--cleanup-grace", type=float, default=DEFAULT_GRACE_TIMEOUT)
84
+ parser.add_argument(
85
+ "application",
86
+ nargs=argparse.REMAINDER,
87
+ metavar="APP [ARG ...]",
88
+ help="application argv after --",
89
+ )
90
+ return parser
91
+
92
+
93
+ def _diagnose() -> int:
94
+ """Report immutable bundled inputs and actionable host prerequisites."""
95
+ from .live_config import LIVE_CLI_PATH, NETWORK_PROFILES_PATH
96
+
97
+ failed = False
98
+ print(f"elsewindow: {__version__}")
99
+ try:
100
+ wrapper_version = distribution_version("ssh-wrapper")
101
+ except PackageNotFoundError:
102
+ wrapper_version = "missing"
103
+ failed = True
104
+ print(f"ssh-wrapper: {wrapper_version}")
105
+ for label, path in (
106
+ ("live-cli.yml", LIVE_CLI_PATH),
107
+ ("profiles.yml", NETWORK_PROFILES_PATH),
108
+ ):
109
+ try:
110
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
111
+ except OSError as error:
112
+ print(f"elsewindow: invalid_resource: {label}: {error}", file=sys.stderr)
113
+ failed = True
114
+ else:
115
+ print(f"{label}: sha256:{digest}")
116
+ if not sys.platform.startswith("linux"):
117
+ print(
118
+ f"elsewindow: unsupported_platform: Linux is required, found {sys.platform}",
119
+ file=sys.stderr,
120
+ )
121
+ failed = True
122
+ for command in ("ssh", "false", "xpra"):
123
+ resolved = shutil.which(command)
124
+ if resolved is None:
125
+ print(
126
+ "elsewindow: missing_dependency: "
127
+ f"required command not found on PATH: {command}",
128
+ file=sys.stderr,
129
+ )
130
+ failed = True
131
+ else:
132
+ print(f"{command}: {resolved}")
133
+ return int(failed)
134
+
135
+
136
+ async def _run_with_signals(config: XpraConfig) -> int:
137
+ session = XpraSession(config)
138
+ loop = asyncio.get_running_loop()
139
+ interrupted: asyncio.Future[int] = loop.create_future()
140
+
141
+ def stop(exit_code: int) -> None:
142
+ if not interrupted.done():
143
+ interrupted.set_result(exit_code)
144
+
145
+ installed: list[signal.Signals] = []
146
+ for selected in (signal.SIGINT, signal.SIGTERM):
147
+ try:
148
+ loop.add_signal_handler(selected, stop, 128 + selected)
149
+ except NotImplementedError:
150
+ continue
151
+ installed.append(selected)
152
+
153
+ run_task = asyncio.create_task(session.run())
154
+ try:
155
+ done, _pending = await asyncio.wait(
156
+ {run_task, interrupted}, return_when=asyncio.FIRST_COMPLETED
157
+ )
158
+ if interrupted in done:
159
+ run_task.cancel()
160
+ with suppress(asyncio.CancelledError):
161
+ await run_task
162
+ return interrupted.result()
163
+ return run_task.result()
164
+ finally:
165
+ if not interrupted.done():
166
+ interrupted.cancel()
167
+ for selected in installed:
168
+ loop.remove_signal_handler(selected)
169
+
170
+
171
+ def main(argv: Sequence[str] | None = None) -> int:
172
+ """Validate configuration, run one session, and return a stable exit code."""
173
+ parser = build_parser()
174
+ args = parser.parse_args(argv)
175
+ if args.diagnose:
176
+ if (
177
+ args.ssh_alias is not None
178
+ or args.host is not None
179
+ or args.user is not None
180
+ or args.port != 22
181
+ or args.application
182
+ ):
183
+ parser.error("--diagnose cannot be combined with a session authority")
184
+ return _diagnose()
185
+ if args.ssh_alias is None and args.host is None:
186
+ parser.error("provide either --ssh-alias or --host")
187
+ try:
188
+ config = XpraConfig.from_namespace(args)
189
+ except SSHError as error:
190
+ parser.error(error.message)
191
+
192
+ try:
193
+ return asyncio.run(_run_with_signals(config))
194
+ except KeyboardInterrupt:
195
+ return 130
196
+ except SSHError as error:
197
+ print(f"elsewindow: {error.code}: {error.message}", file=sys.stderr)
198
+ return 1
199
+ except Exception as error: # noqa: BLE001 - public diagnostics stay sanitized.
200
+ print(
201
+ f"elsewindow: session_failed: {type(error).__name__}",
202
+ file=sys.stderr,
203
+ )
204
+ return 1
elsewindow/config.py ADDED
@@ -0,0 +1,152 @@
1
+ # Copyright (c) 2026 kogeler
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Validated immutable configuration for one remote Xpra session."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import math
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from ssh_wrapper.connection import ConnectionSpec, resolve_program
14
+ from ssh_wrapper.errors import SSHError
15
+
16
+ from . import live_config
17
+
18
+ DEFAULT_CONNECT_TIMEOUT = 120.0
19
+ DEFAULT_READY_TIMEOUT = 45.0
20
+ DEFAULT_PROBE_TIMEOUT = 8.0
21
+ DEFAULT_POLL_INTERVAL = 1.0
22
+ DEFAULT_HEARTBEAT_INTERVAL = 10.0
23
+ DEFAULT_LEASE_TIMEOUT = 45.0
24
+ DEFAULT_GRACE_TIMEOUT = 5.0
25
+ MAX_TIMEOUT = 900.0
26
+ MAX_APPLICATION_ARGUMENTS = 256
27
+ MAX_APPLICATION_BYTES = 16 * 1024
28
+ DEFAULT_ENCODING_PROFILE = live_config.DEFAULT_ENCODING_PROFILE
29
+ DEFAULT_NETWORK_PROFILE = live_config.load_network_profiles()[0]
30
+ SUPPORTED_ENCODING_PROFILES = live_config.encoding_profile_names()
31
+ SUPPORTED_NETWORK_PROFILES = live_config.network_profile_names()
32
+
33
+
34
+ def _bounded_float(name: str, value: float, minimum: float = 0.1) -> float:
35
+ if not math.isfinite(value) or not minimum <= value <= MAX_TIMEOUT:
36
+ raise SSHError(
37
+ "invalid_configuration",
38
+ f"{name} must be between {minimum:g} and {MAX_TIMEOUT:g} seconds",
39
+ )
40
+ return value
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class XpraConfig:
45
+ """All startup policy for one independently owned GUI session."""
46
+
47
+ connection: ConnectionSpec
48
+ application: tuple[str, ...]
49
+ encoding_profile: str
50
+ network_profile: str
51
+ connect_timeout: float
52
+ ready_timeout: float
53
+ probe_timeout: float
54
+ poll_interval: float
55
+ heartbeat_interval: float
56
+ lease_timeout: float
57
+ grace_timeout: float
58
+ ssh_path: Path
59
+ false_path: Path
60
+ xpra_path: Path
61
+
62
+ @classmethod
63
+ def from_namespace(cls, args: argparse.Namespace) -> XpraConfig:
64
+ """Validate parsed arguments and resolve required local executables."""
65
+ if args.ssh_alias is not None:
66
+ if args.host is not None or args.user is not None or args.port != 22:
67
+ raise SSHError(
68
+ "invalid_connection",
69
+ "ssh alias cannot be combined with host, user, or a direct port",
70
+ )
71
+ connection = ConnectionSpec.from_alias(args.ssh_alias)
72
+ else:
73
+ if args.host is None or args.user is None:
74
+ raise SSHError(
75
+ "invalid_connection",
76
+ "provide either ssh alias or both host and user",
77
+ )
78
+ connection = ConnectionSpec.from_direct(args.host, args.user, args.port)
79
+
80
+ application = tuple(args.application)
81
+ if application and application[0] == "--":
82
+ application = application[1:]
83
+ if not application or not application[0]:
84
+ raise SSHError(
85
+ "invalid_application", "provide an application argv after --"
86
+ )
87
+ if len(application) > MAX_APPLICATION_ARGUMENTS:
88
+ raise SSHError(
89
+ "invalid_application",
90
+ f"application argv is limited to {MAX_APPLICATION_ARGUMENTS} items",
91
+ )
92
+ if any("\x00" in item for item in application):
93
+ raise SSHError("invalid_application", "application argv contains a NUL")
94
+ if (
95
+ sum(len(item.encode("utf-8")) for item in application)
96
+ > MAX_APPLICATION_BYTES
97
+ ):
98
+ raise SSHError(
99
+ "invalid_application",
100
+ f"application argv is limited to {MAX_APPLICATION_BYTES} UTF-8 bytes",
101
+ )
102
+ if args.encoding_profile not in SUPPORTED_ENCODING_PROFILES:
103
+ raise SSHError(
104
+ "invalid_configuration",
105
+ "encoding profile must be one of: "
106
+ f"{', '.join(SUPPORTED_ENCODING_PROFILES)}",
107
+ )
108
+ if args.network_profile not in SUPPORTED_NETWORK_PROFILES:
109
+ raise SSHError(
110
+ "invalid_configuration",
111
+ "network profile must be one of: "
112
+ f"{', '.join(SUPPORTED_NETWORK_PROFILES)}",
113
+ )
114
+ heartbeat_interval = _bounded_float(
115
+ "heartbeat interval", args.heartbeat_interval
116
+ )
117
+ lease_timeout = _bounded_float("lease timeout", args.lease_timeout)
118
+ if lease_timeout <= heartbeat_interval * 2:
119
+ raise SSHError(
120
+ "invalid_configuration",
121
+ "lease timeout must be greater than twice the heartbeat interval",
122
+ )
123
+ return cls(
124
+ connection=connection,
125
+ application=application,
126
+ encoding_profile=args.encoding_profile,
127
+ network_profile=args.network_profile,
128
+ connect_timeout=_bounded_float("connect timeout", args.connect_timeout),
129
+ ready_timeout=_bounded_float("ready timeout", args.ready_timeout),
130
+ probe_timeout=_bounded_float("probe timeout", args.probe_timeout),
131
+ poll_interval=_bounded_float("poll interval", args.poll_interval),
132
+ heartbeat_interval=heartbeat_interval,
133
+ lease_timeout=lease_timeout,
134
+ grace_timeout=_bounded_float("cleanup grace", args.cleanup_grace),
135
+ ssh_path=resolve_program("ssh"),
136
+ false_path=resolve_program("false"),
137
+ xpra_path=resolve_program("xpra"),
138
+ )
139
+
140
+ @property
141
+ def authority_uri(self) -> str:
142
+ """Return the SSH URI prefix consumed by the local Xpra client."""
143
+ connection = self.connection
144
+ if connection.ssh_alias is not None:
145
+ authority = connection.ssh_alias
146
+ else:
147
+ assert connection.host is not None
148
+ assert connection.user is not None
149
+ assert connection.port is not None
150
+ host = f"[{connection.host}]" if ":" in connection.host else connection.host
151
+ authority = f"{connection.user}@{host}:{connection.port}"
152
+ return f"ssh://{authority}"
@@ -0,0 +1,94 @@
1
+ # Static Xpra CLI blocks used by the live server and client command builders.
2
+ # Values are grouped role -> concern/command/transport -> encoding -> policy;
3
+ # per-run network/quality values live in profiles.yml rather than here.
4
+ # Loader and assembly: infra/live/live_config.py and infra/live/run.py.
5
+ # Generic schema tests: infra/live/test_job.py. Contract and usage:
6
+ # CONTRACT.md and docs/runbooks/live-tests.md.
7
+ schema: 1
8
+ server:
9
+ base:
10
+ - "--minimal"
11
+ - "--backend=wayland"
12
+ - "--daemon=no"
13
+ - "--displayfd=1"
14
+ - "--socket-dir=/tmp/server-runtime/xpra-sockets"
15
+ - "--socket-dirs=/tmp/server-runtime/xpra-sockets"
16
+ - "--sessions-dir=/tmp/server-runtime/xpra-sessions"
17
+ lifecycle:
18
+ - "--use-display=no"
19
+ - "--exit-with-client=no"
20
+ - "--exit-with-children=yes"
21
+ - "--terminate-children=yes"
22
+ - "--video-scaling=0"
23
+ - "--html=off"
24
+ diagnostics:
25
+ - "-d"
26
+ - "wayland,damage,encoding,encoder,argb"
27
+ commands:
28
+ version:
29
+ - "--version"
30
+ info:
31
+ - "wayland-0"
32
+ - "--socket-dir=/tmp/server-runtime/xpra-sockets"
33
+ transports:
34
+ rgb:
35
+ common:
36
+ - "--video-encoders=none"
37
+ - "--csc-modules=none"
38
+ policies:
39
+ strict:
40
+ - "--encodings=rgb"
41
+ h264:
42
+ common:
43
+ - "--video=yes"
44
+ - "--video-encoders=libva"
45
+ - "--csc-modules=libyuv"
46
+ policies:
47
+ strict:
48
+ - "--encodings=h264"
49
+ adaptive-alpha:
50
+ - "--encodings=h264,webp,rgb"
51
+ fallback-auto:
52
+ - "--encodings=h264,rgb"
53
+ fallback-h264:
54
+ - "--encodings=h264,rgb"
55
+ client:
56
+ base:
57
+ - "--minimal"
58
+ - "--compressors=none"
59
+ - "--reconnect=no"
60
+ - "--bandwidth-detection=no"
61
+ diagnostics:
62
+ - "-d"
63
+ - "draw,paint,cairo,window,gtk,alpha,libva"
64
+ commands:
65
+ version:
66
+ - "--version"
67
+ detach:
68
+ - "--compressors=none"
69
+ transports:
70
+ rgb:
71
+ common:
72
+ - "--opengl=no"
73
+ - "--video-decoders=none"
74
+ - "--csc-modules=none"
75
+ policies:
76
+ strict:
77
+ - "--encodings=rgb"
78
+ h264:
79
+ common:
80
+ - "--video=yes"
81
+ - "--opengl=force:native"
82
+ - "--video-decoders=libva"
83
+ - "--csc-modules=none"
84
+ policies:
85
+ strict:
86
+ - "--encodings=h264"
87
+ adaptive-alpha:
88
+ - "--encodings=h264,webp,rgb"
89
+ - "--encoding=h264"
90
+ fallback-auto:
91
+ - "--encodings=h264,rgb"
92
+ fallback-h264:
93
+ - "--encodings=h264,rgb"
94
+ - "--encoding=h264"