elsewindow 0.2.0__tar.gz → 0.2.1__tar.gz
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-0.2.1/.version +1 -0
- {elsewindow-0.2.0 → elsewindow-0.2.1}/CHANGELOG.md +73 -0
- {elsewindow-0.2.0 → elsewindow-0.2.1}/PKG-INFO +17 -10
- {elsewindow-0.2.0 → elsewindow-0.2.1}/README.md +15 -8
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/_persistent_agent.py +146 -37
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/cli.py +69 -3
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/config.py +24 -0
- elsewindow-0.2.1/elsewindow/desktop.py +480 -0
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/journal.py +129 -26
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/live-cli.yml +3 -1
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/persistent.py +102 -10
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/requirements-xpra-build.in +1 -1
- elsewindow-0.2.1/elsewindow/requirements-xpra-build.txt +121 -0
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/session.py +168 -32
- elsewindow-0.2.1/elsewindow/session_bus.py +161 -0
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/xpra_runtime.py +52 -19
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow.egg-info/PKG-INFO +17 -10
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow.egg-info/SOURCES.txt +1 -0
- elsewindow-0.2.1/elsewindow.egg-info/requires.txt +1 -0
- {elsewindow-0.2.0 → elsewindow-0.2.1}/requirements.in +1 -1
- elsewindow-0.2.0/.version +0 -1
- elsewindow-0.2.0/elsewindow/requirements-xpra-build.txt +0 -121
- elsewindow-0.2.0/elsewindow/session_bus.py +0 -93
- elsewindow-0.2.0/elsewindow.egg-info/requires.txt +0 -1
- {elsewindow-0.2.0 → elsewindow-0.2.1}/LICENSE +0 -0
- {elsewindow-0.2.0 → elsewindow-0.2.1}/MANIFEST.in +0 -0
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/__init__.py +0 -0
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/__main__.py +0 -0
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/live_config.py +0 -0
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/log_transport.py +0 -0
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/machine.py +0 -0
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/profiles.yml +0 -0
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/py.typed +0 -0
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/requirements-xpra.in +0 -0
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/requirements-xpra.txt +0 -0
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow.egg-info/dependency_links.txt +0 -0
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow.egg-info/entry_points.txt +0 -0
- {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow.egg-info/top_level.txt +0 -0
- {elsewindow-0.2.0 → elsewindow-0.2.1}/pyproject.toml +0 -0
- {elsewindow-0.2.0 → elsewindow-0.2.1}/setup.cfg +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.2.1
|
|
@@ -6,6 +6,79 @@ All notable changes to Elsewindow are documented here.
|
|
|
6
6
|
|
|
7
7
|
## Unreleased
|
|
8
8
|
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Submit the two Xpra environment locks together with the other six to the
|
|
12
|
+
GitHub dependency graph. Since 0.2.0 the push-only submission job rejected the
|
|
13
|
+
generated snapshot; a unit test now keeps its accepted manifest list equal to
|
|
14
|
+
the generator's output.
|
|
15
|
+
|
|
16
|
+
## [0.2.1] - 2026-09-08
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Include the standard desktop portal and GTK backend in explicit system-package
|
|
21
|
+
preparation and the release-backed disposable images. Startup never installs
|
|
22
|
+
system packages.
|
|
23
|
+
- Own a private foreground desktop portal, GTK backend and transient permission
|
|
24
|
+
store for remote file dialogs and portal notifications. OpenURI and unrelated
|
|
25
|
+
desktop portal methods remain disabled.
|
|
26
|
+
- Prevent repeated child-start requests from launching a second application or
|
|
27
|
+
competing portal helpers within the same owned session.
|
|
28
|
+
- Add repeatable `--env NAME=VALUE` and `--env NAME` application overrides for
|
|
29
|
+
ordinary and persistent sessions. Preserve literal and empty values, isolate
|
|
30
|
+
overrides from session helpers, and require matching values on persistent
|
|
31
|
+
reconnects.
|
|
32
|
+
- Document that Zed's default `notify_when_agent_waiting` opens no agent
|
|
33
|
+
notification window in any Wayland session, including Elsewindow's, and that
|
|
34
|
+
`all_screens` delivers them as forwarded windows.
|
|
35
|
+
- Cover application-drawn agent notification windows in the live test with a
|
|
36
|
+
lightweight probe of Zed's Wayland flow, the operator's persistent H.264
|
|
37
|
+
`gigabit_lan` command line, and a real xfwm4 desktop across a persistent
|
|
38
|
+
reconnect.
|
|
39
|
+
- Resume the ordered live matrix at a fixed failure with
|
|
40
|
+
`make live-test LIVE_FROM=CASE`, and run selected tests without the coverage
|
|
41
|
+
gate with `make test-focused TESTS=...`. Only a complete live run validates a
|
|
42
|
+
change.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- Update the published SSH runtime dependency through its single requirements
|
|
47
|
+
input. Derive environment, lock, clean-install, standalone and live identity
|
|
48
|
+
checks from that input instead of maintaining duplicate version constants.
|
|
49
|
+
- Missing journald no longer prevents ordinary or persistent application startup;
|
|
50
|
+
warn with setup guidance and retain terminal output and the remote log relay.
|
|
51
|
+
- Check optional notification and GPU prerequisites on both hosts. Missing
|
|
52
|
+
support disables delivery or selects RGB for this connection with explicit
|
|
53
|
+
package guidance; absent portal services never prevent application startup.
|
|
54
|
+
- Unavailable persistence or declined linger falls back to an ordinary session
|
|
55
|
+
only when no persistent state is recorded, with a warning about disconnect
|
|
56
|
+
cleanup. Never replace or duplicate an existing persistent application.
|
|
57
|
+
- Synchronized the maintained fork's client wheel default and pointer diagnostics
|
|
58
|
+
without duplicating the mirrored wheel option in the session policy.
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- Prevent an open but unread SSH output pipe from blocking ordinary remote
|
|
63
|
+
shutdown. Bound raw-output queues, retain native journal processing, and report
|
|
64
|
+
dropped bytes without postponing owned application cleanup.
|
|
65
|
+
- Wait for the remote dialog's actual focus before sending its dismissal key
|
|
66
|
+
in the live GUI test. Repeat abrupt master loss and retain bounded process,
|
|
67
|
+
owned-path and shutdown-journal evidence on cleanup failure.
|
|
68
|
+
- Allow local Xpra environment preparation, reuse, and startup on shared
|
|
69
|
+
filesystems with mapped ownership or broader Unix permissions.
|
|
70
|
+
- Use the same resolved Xpra executable for preparation, diagnosis, and session
|
|
71
|
+
startup when PATH contains symbolic links. Allow longer installed-file checks
|
|
72
|
+
on shared storage and report timeouts without treating them as stale
|
|
73
|
+
environments or reinstalling dependencies.
|
|
74
|
+
- Read installed file inventories directly with bounded concurrent reads during
|
|
75
|
+
Xpra validation to avoid repeated filesystem metadata scans and detect missing
|
|
76
|
+
package files.
|
|
77
|
+
- Consume the canonical Xpra client modal-window default from the mirrored YAML
|
|
78
|
+
instead of duplicating it in Python.
|
|
79
|
+
- Mask the current packaged Xpra socket and service in the disposable target so
|
|
80
|
+
its only TCP listener is SSH.
|
|
81
|
+
|
|
9
82
|
## [0.2.0] - 2026-09-05
|
|
10
83
|
|
|
11
84
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: elsewindow
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Run one remote Linux GUI application through Xpra over one owned OpenSSH master
|
|
5
5
|
Author: kogeler
|
|
6
6
|
Maintainer: kogeler
|
|
@@ -25,7 +25,7 @@ Classifier: Typing :: Typed
|
|
|
25
25
|
Requires-Python: <3.15,>=3.13
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
27
27
|
License-File: LICENSE
|
|
28
|
-
Requires-Dist: ssh-wrapper==0.1.
|
|
28
|
+
Requires-Dist: ssh-wrapper==0.1.1
|
|
29
29
|
Dynamic: license-file
|
|
30
30
|
|
|
31
31
|
# Elsewindow
|
|
@@ -37,22 +37,26 @@ cleans only the server, application process group, sockets, and runtime state
|
|
|
37
37
|
created by that invocation.
|
|
38
38
|
|
|
39
39
|
The application renders through the remote Wayland compositor and remote GPU.
|
|
40
|
-
Only Xpra picture, input, clipboard, and control traffic crosses SSH.
|
|
40
|
+
Only Xpra picture, input, clipboard, notification, and control traffic crosses SSH.
|
|
41
41
|
|
|
42
42
|
## Install And Run
|
|
43
43
|
|
|
44
44
|
The Python distribution requires Linux, CPython 3.13 or 3.14, OpenSSH,
|
|
45
|
-
`false`, a local graphical session, and compatible Xpra packages
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
`false`, a local graphical session, and compatible Xpra packages on both
|
|
46
|
+
systems. [Optional desktop features](doc/cli.md#optional-desktop-features)
|
|
47
|
+
have separate system prerequisites: missing support produces a warning and
|
|
48
|
+
disables only that feature for the session. Startup never installs packages.
|
|
49
|
+
Install the latest published release with pip or pipx:
|
|
48
50
|
|
|
49
51
|
```bash
|
|
50
|
-
python3.14 -m pip install
|
|
51
|
-
# or: pipx install
|
|
52
|
+
python3.14 -m pip install elsewindow
|
|
53
|
+
# or: pipx install elsewindow
|
|
52
54
|
elsewindow --prepare-xpra
|
|
53
55
|
elsewindow --ssh-alias agents-a -- xterm
|
|
54
56
|
```
|
|
55
57
|
|
|
58
|
+
The version prepared by this source tree is recorded only in [`.version`](.version).
|
|
59
|
+
|
|
56
60
|
Check packaged versions, profile digests, Linux support, and local commands
|
|
57
61
|
without opening a connection:
|
|
58
62
|
|
|
@@ -61,7 +65,8 @@ elsewindow --diagnose
|
|
|
61
65
|
```
|
|
62
66
|
|
|
63
67
|
The published distribution resolves the exact reviewed
|
|
64
|
-
[`ssh-wrapper
|
|
68
|
+
[`ssh-wrapper`](https://pypi.org/project/ssh-wrapper/) release pinned only in
|
|
69
|
+
[`requirements.in`](requirements.in).
|
|
65
70
|
For a source checkout, prepare the hash-locked runtime and use the repository
|
|
66
71
|
launcher from any working directory:
|
|
67
72
|
|
|
@@ -91,7 +96,9 @@ elsewindow \
|
|
|
91
96
|
|
|
92
97
|
See the [CLI reference](doc/cli.md) for every option, default, and allowed
|
|
93
98
|
value, including encoding/network profiles, clipboard policy, logging, and
|
|
94
|
-
persistence.
|
|
99
|
+
persistence. Repeat [`--env`](doc/cli.md#application-environment) to set remote
|
|
100
|
+
application variables or copy explicitly named local values. Reviewed profile
|
|
101
|
+
arguments come from the packaged YAML mirrors.
|
|
95
102
|
The [Xpra guide](doc/xpra.md) covers hardware, application, and lifecycle
|
|
96
103
|
behavior; the [security model](doc/security.md) explains clipboard and logging
|
|
97
104
|
trust boundaries.
|
|
@@ -7,22 +7,26 @@ cleans only the server, application process group, sockets, and runtime state
|
|
|
7
7
|
created by that invocation.
|
|
8
8
|
|
|
9
9
|
The application renders through the remote Wayland compositor and remote GPU.
|
|
10
|
-
Only Xpra picture, input, clipboard, and control traffic crosses SSH.
|
|
10
|
+
Only Xpra picture, input, clipboard, notification, and control traffic crosses SSH.
|
|
11
11
|
|
|
12
12
|
## Install And Run
|
|
13
13
|
|
|
14
14
|
The Python distribution requires Linux, CPython 3.13 or 3.14, OpenSSH,
|
|
15
|
-
`false`, a local graphical session, and compatible Xpra packages
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
`false`, a local graphical session, and compatible Xpra packages on both
|
|
16
|
+
systems. [Optional desktop features](doc/cli.md#optional-desktop-features)
|
|
17
|
+
have separate system prerequisites: missing support produces a warning and
|
|
18
|
+
disables only that feature for the session. Startup never installs packages.
|
|
19
|
+
Install the latest published release with pip or pipx:
|
|
18
20
|
|
|
19
21
|
```bash
|
|
20
|
-
python3.14 -m pip install
|
|
21
|
-
# or: pipx install
|
|
22
|
+
python3.14 -m pip install elsewindow
|
|
23
|
+
# or: pipx install elsewindow
|
|
22
24
|
elsewindow --prepare-xpra
|
|
23
25
|
elsewindow --ssh-alias agents-a -- xterm
|
|
24
26
|
```
|
|
25
27
|
|
|
28
|
+
The version prepared by this source tree is recorded only in [`.version`](.version).
|
|
29
|
+
|
|
26
30
|
Check packaged versions, profile digests, Linux support, and local commands
|
|
27
31
|
without opening a connection:
|
|
28
32
|
|
|
@@ -31,7 +35,8 @@ elsewindow --diagnose
|
|
|
31
35
|
```
|
|
32
36
|
|
|
33
37
|
The published distribution resolves the exact reviewed
|
|
34
|
-
[`ssh-wrapper
|
|
38
|
+
[`ssh-wrapper`](https://pypi.org/project/ssh-wrapper/) release pinned only in
|
|
39
|
+
[`requirements.in`](requirements.in).
|
|
35
40
|
For a source checkout, prepare the hash-locked runtime and use the repository
|
|
36
41
|
launcher from any working directory:
|
|
37
42
|
|
|
@@ -61,7 +66,9 @@ elsewindow \
|
|
|
61
66
|
|
|
62
67
|
See the [CLI reference](doc/cli.md) for every option, default, and allowed
|
|
63
68
|
value, including encoding/network profiles, clipboard policy, logging, and
|
|
64
|
-
persistence.
|
|
69
|
+
persistence. Repeat [`--env`](doc/cli.md#application-environment) to set remote
|
|
70
|
+
application variables or copy explicitly named local values. Reviewed profile
|
|
71
|
+
arguments come from the packaged YAML mirrors.
|
|
65
72
|
The [Xpra guide](doc/xpra.md) covers hardware, application, and lifecycle
|
|
66
73
|
behavior; the [security model](doc/security.md) explains clipboard and logging
|
|
67
74
|
trust boundaries.
|
|
@@ -26,6 +26,11 @@ from contextlib import contextmanager
|
|
|
26
26
|
from pathlib import Path
|
|
27
27
|
from typing import Any
|
|
28
28
|
|
|
29
|
+
from elsewindow.desktop import (
|
|
30
|
+
APPLICATION_LOADER,
|
|
31
|
+
application_argv,
|
|
32
|
+
validate_application_environment,
|
|
33
|
+
)
|
|
29
34
|
from elsewindow.journal import (
|
|
30
35
|
DEFAULT_LOG_LEVEL,
|
|
31
36
|
LOG_LEVELS,
|
|
@@ -44,7 +49,7 @@ SCHEMA = 1
|
|
|
44
49
|
MAX_BYTES = 128 * 1024
|
|
45
50
|
COMMAND_TIMEOUT = 15
|
|
46
51
|
LOADER = REMOTE_LOADER
|
|
47
|
-
APP_LOADER =
|
|
52
|
+
APP_LOADER = APPLICATION_LOADER
|
|
48
53
|
IDENTITY = re.compile(r"[a-f0-9]{64}")
|
|
49
54
|
TOKEN = re.compile(r"[a-f0-9]{32}")
|
|
50
55
|
DISPLAY = re.compile(r"wayland-[0-9]+")
|
|
@@ -135,18 +140,24 @@ def prerequisites() -> dict[str, Any]:
|
|
|
135
140
|
"persistent_systemd_unavailable",
|
|
136
141
|
"persistent sessions require systemd, loginctl and systemd-run on the remote host",
|
|
137
142
|
)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if not manager:
|
|
146
|
-
raise AgentError(
|
|
147
|
-
"persistent_systemd_unavailable",
|
|
148
|
-
"the remote systemd user manager is not available",
|
|
143
|
+
try:
|
|
144
|
+
linger = linger_enabled()
|
|
145
|
+
manager = False
|
|
146
|
+
if linger:
|
|
147
|
+
runtime_directory()
|
|
148
|
+
manager = (
|
|
149
|
+
run("/usr/bin/systemctl", "--user", "show-environment").returncode == 0
|
|
149
150
|
)
|
|
151
|
+
if not manager:
|
|
152
|
+
raise AgentError(
|
|
153
|
+
"persistent_systemd_unavailable",
|
|
154
|
+
"the remote systemd user manager is not available",
|
|
155
|
+
)
|
|
156
|
+
except (OSError, subprocess.SubprocessError) as error:
|
|
157
|
+
raise AgentError(
|
|
158
|
+
"persistent_systemd_unavailable",
|
|
159
|
+
"the remote systemd user manager is unavailable",
|
|
160
|
+
) from error
|
|
150
161
|
return {"linger": linger, "manager": manager}
|
|
151
162
|
|
|
152
163
|
|
|
@@ -173,7 +184,20 @@ def enable_linger() -> dict[str, Any]:
|
|
|
173
184
|
return {"linger": True}
|
|
174
185
|
|
|
175
186
|
|
|
176
|
-
def
|
|
187
|
+
def request_environment(request: dict[str, Any]) -> dict[str, str]:
|
|
188
|
+
try:
|
|
189
|
+
return validate_application_environment(
|
|
190
|
+
request.get("application_environment", {})
|
|
191
|
+
)
|
|
192
|
+
except ValueError as error:
|
|
193
|
+
raise AgentError(
|
|
194
|
+
"invalid_application_environment", "the application environment is invalid"
|
|
195
|
+
) from error
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def canonical_application(
|
|
199
|
+
value: Any, environment: dict[str, str] | None = None
|
|
200
|
+
) -> list[str]:
|
|
177
201
|
if (
|
|
178
202
|
not isinstance(value, list)
|
|
179
203
|
or not value
|
|
@@ -186,7 +210,9 @@ def canonical_application(value: Any) -> list[str]:
|
|
|
186
210
|
)
|
|
187
211
|
executable = value[0]
|
|
188
212
|
if "/" not in executable:
|
|
189
|
-
executable =
|
|
213
|
+
executable = (
|
|
214
|
+
shutil.which(executable, path=(environment or {}).get("PATH")) or ""
|
|
215
|
+
)
|
|
190
216
|
if not executable:
|
|
191
217
|
raise AgentError(
|
|
192
218
|
"invalid_application", "the remote application executable was not found"
|
|
@@ -206,6 +232,21 @@ def state_root() -> Path:
|
|
|
206
232
|
return secure_directory(runtime_directory() / "elsewindow-persistent", create=True)
|
|
207
233
|
|
|
208
234
|
|
|
235
|
+
def ordinary_fallback(request: dict[str, Any]) -> dict[str, bool]:
|
|
236
|
+
"""Never turn a failed resume into a second application instance."""
|
|
237
|
+
application = canonical_application(
|
|
238
|
+
request["application"], request_environment(request)
|
|
239
|
+
)
|
|
240
|
+
key = digest(application)
|
|
241
|
+
try:
|
|
242
|
+
runtime = secure_directory(Path("/run/user") / str(os.getuid()))
|
|
243
|
+
root = secure_directory(runtime / "elsewindow-persistent")
|
|
244
|
+
except FileNotFoundError:
|
|
245
|
+
return {"allowed": True}
|
|
246
|
+
with locked(root, key):
|
|
247
|
+
return {"allowed": read_record(root, key) is None}
|
|
248
|
+
|
|
249
|
+
|
|
209
250
|
@contextmanager
|
|
210
251
|
def locked(root: Path, key: str) -> Iterator[None]:
|
|
211
252
|
if IDENTITY.fullmatch(key) is None:
|
|
@@ -353,15 +394,47 @@ def public_record(record: dict[str, Any], *, created: bool = False) -> dict[str,
|
|
|
353
394
|
"xpra_pid",
|
|
354
395
|
"xpra_start",
|
|
355
396
|
"session_name",
|
|
397
|
+
"server_features",
|
|
356
398
|
)
|
|
357
399
|
} | {"created": created}
|
|
358
400
|
|
|
359
401
|
|
|
402
|
+
def canonical_server(
|
|
403
|
+
server: Any,
|
|
404
|
+
application: list[str],
|
|
405
|
+
root: Path,
|
|
406
|
+
key: str,
|
|
407
|
+
application_environment: dict[str, str],
|
|
408
|
+
) -> list[str]:
|
|
409
|
+
if not isinstance(server, list) or not all(isinstance(arg, str) for arg in server):
|
|
410
|
+
raise AgentError("invalid_configuration", "invalid persistent server command")
|
|
411
|
+
server = [arg.replace("$XDG_RUNTIME_DIR", str(root.parent)) for arg in server]
|
|
412
|
+
child = shlex.join(
|
|
413
|
+
application_argv(
|
|
414
|
+
tuple(application),
|
|
415
|
+
"--notifications=no" not in server,
|
|
416
|
+
application_environment=tuple(application_environment.items()),
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
indexes = [i for i, arg in enumerate(server) if arg.startswith("--start-child=")]
|
|
420
|
+
if len(indexes) != 1:
|
|
421
|
+
raise AgentError(
|
|
422
|
+
"invalid_configuration", "persistent server has no unique child command"
|
|
423
|
+
)
|
|
424
|
+
server[indexes[0]] = f"--start-child={child}"
|
|
425
|
+
session_name = f"elsewindow-{key[:16]}"
|
|
426
|
+
return [
|
|
427
|
+
f"--session-name={session_name}" if arg.startswith("--session-name=") else arg
|
|
428
|
+
for arg in server
|
|
429
|
+
]
|
|
430
|
+
|
|
431
|
+
|
|
360
432
|
def ensure(request: dict[str, Any], source: str) -> dict[str, Any]:
|
|
361
433
|
"""Serialize lookup and creation; never stop or replace an existing service."""
|
|
362
434
|
# Complete an accepted launch even if the SSH command loses its terminal.
|
|
363
435
|
signal.signal(signal.SIGHUP, signal.SIG_IGN)
|
|
364
|
-
|
|
436
|
+
application_environment = request_environment(request)
|
|
437
|
+
application = canonical_application(request["application"], application_environment)
|
|
365
438
|
key = digest(application)
|
|
366
439
|
session = log_session(key)
|
|
367
440
|
if request.get("log_session", session) != session:
|
|
@@ -372,23 +445,29 @@ def ensure(request: dict[str, Any], source: str) -> dict[str, Any]:
|
|
|
372
445
|
if level not in LOG_LEVELS:
|
|
373
446
|
raise AgentError("invalid_configuration", "invalid logging policy")
|
|
374
447
|
root = state_root()
|
|
375
|
-
server =
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
448
|
+
server = canonical_server(
|
|
449
|
+
request["server"], application, root, key, application_environment
|
|
450
|
+
)
|
|
451
|
+
requested = canonical_server(
|
|
452
|
+
request.get("requested_server", request["server"]),
|
|
453
|
+
application,
|
|
454
|
+
root,
|
|
455
|
+
key,
|
|
456
|
+
application_environment,
|
|
457
|
+
)
|
|
458
|
+
requested_sha256 = digest(["xpra", *requested[3:]])
|
|
386
459
|
session_name = f"elsewindow-{key[:16]}"
|
|
387
|
-
server = [
|
|
388
|
-
f"--session-name={session_name}" if arg.startswith("--session-name=") else arg
|
|
389
|
-
for arg in server
|
|
390
|
-
]
|
|
391
460
|
argv_sha256 = digest(["xpra", *server[3:]])
|
|
461
|
+
features = request.get("server_features")
|
|
462
|
+
if features is not None and (
|
|
463
|
+
not isinstance(features, dict)
|
|
464
|
+
or set(features) != {"notifications", "encoding_profile"}
|
|
465
|
+
or type(features["notifications"]) is not bool
|
|
466
|
+
or not isinstance(features["encoding_profile"], str)
|
|
467
|
+
or len(features["encoding_profile"]) > 64
|
|
468
|
+
or features["notifications"] != ("--notifications=no" not in server)
|
|
469
|
+
):
|
|
470
|
+
raise AgentError("invalid_configuration", "invalid persistent feature policy")
|
|
392
471
|
with locked(root, key):
|
|
393
472
|
record = read_record(root, key)
|
|
394
473
|
state = unit_state(key)
|
|
@@ -396,9 +475,17 @@ def ensure(request: dict[str, Any], source: str) -> dict[str, Any]:
|
|
|
396
475
|
verify_unit(record, state)
|
|
397
476
|
if state.get("ActiveState") in {"active", "activating", "reloading"}:
|
|
398
477
|
assert record is not None
|
|
478
|
+
if record.get("application_environment", {}) != application_environment:
|
|
479
|
+
raise AgentError(
|
|
480
|
+
"persistent_environment_mismatch",
|
|
481
|
+
"the existing session uses different application environment values; reconnect with its original --env values or exit the application first",
|
|
482
|
+
)
|
|
399
483
|
if (
|
|
400
484
|
record.get("application") != application
|
|
401
|
-
or record.get("argv_sha256")
|
|
485
|
+
or record.get("requested_argv_sha256", record.get("argv_sha256"))
|
|
486
|
+
!= requested_sha256
|
|
487
|
+
or record.get("argv_sha256")
|
|
488
|
+
!= digest(["xpra", *record["server"][3:]])
|
|
402
489
|
or record.get("log_level") != level
|
|
403
490
|
or record.get("journal_protocol") != LOG_PROTOCOL
|
|
404
491
|
or record.get("log_session") != session
|
|
@@ -417,8 +504,11 @@ def ensure(request: dict[str, Any], source: str) -> dict[str, Any]:
|
|
|
417
504
|
"key": key,
|
|
418
505
|
"token": secrets.token_hex(16),
|
|
419
506
|
"application": application,
|
|
507
|
+
"application_environment": application_environment,
|
|
420
508
|
"server": server,
|
|
421
509
|
"argv_sha256": argv_sha256,
|
|
510
|
+
"requested_argv_sha256": requested_sha256,
|
|
511
|
+
"server_features": features,
|
|
422
512
|
"cwd": str(Path.cwd()),
|
|
423
513
|
"environment": {
|
|
424
514
|
key: os.environ[key]
|
|
@@ -529,7 +619,7 @@ def serve(request: dict[str, Any]) -> None:
|
|
|
529
619
|
"server",
|
|
530
620
|
record["log_session"],
|
|
531
621
|
)
|
|
532
|
-
journal.
|
|
622
|
+
journal.open_optional()
|
|
533
623
|
record.update(
|
|
534
624
|
worker_pid=os.getpid(),
|
|
535
625
|
worker_start=process_start(os.getpid()),
|
|
@@ -540,7 +630,10 @@ def serve(request: dict[str, Any]) -> None:
|
|
|
540
630
|
stdin=subprocess.DEVNULL,
|
|
541
631
|
stdout=subprocess.PIPE,
|
|
542
632
|
stderr=subprocess.PIPE,
|
|
543
|
-
env=bus.
|
|
633
|
+
env=bus.start_optional(
|
|
634
|
+
xpra_environment(journal.level),
|
|
635
|
+
lambda message: journal.emit(PRIORITIES["warning"], message),
|
|
636
|
+
),
|
|
544
637
|
)
|
|
545
638
|
record.update(xpra_pid=process.pid, xpra_start=process_start(process.pid))
|
|
546
639
|
write_record(root, record)
|
|
@@ -621,9 +714,11 @@ def main(arguments: list[str]) -> int:
|
|
|
621
714
|
request = json.loads(base64.b64decode(encoded, validate=True))
|
|
622
715
|
if not isinstance(request, dict):
|
|
623
716
|
raise TypeError("payload must be an object")
|
|
624
|
-
|
|
717
|
+
result: dict[str, Any]
|
|
718
|
+
if not request.get("log_deferred", False) and action in {
|
|
625
719
|
"identify",
|
|
626
720
|
"probe",
|
|
721
|
+
"ordinary-fallback",
|
|
627
722
|
"enable-linger",
|
|
628
723
|
"ensure",
|
|
629
724
|
"status",
|
|
@@ -634,9 +729,13 @@ def main(arguments: list[str]) -> int:
|
|
|
634
729
|
"server",
|
|
635
730
|
request.get("log_session", ""),
|
|
636
731
|
)
|
|
637
|
-
journal.
|
|
732
|
+
journal.open_optional()
|
|
638
733
|
if action == "identify":
|
|
639
|
-
key = digest(
|
|
734
|
+
key = digest(
|
|
735
|
+
canonical_application(
|
|
736
|
+
request["application"], request_environment(request)
|
|
737
|
+
)
|
|
738
|
+
)
|
|
640
739
|
# No successful record precedes identity negotiation. Errors still
|
|
641
740
|
# use the shared invocation ID supplied by the client.
|
|
642
741
|
result = {"key": key, "log_session": log_session(key)}
|
|
@@ -644,6 +743,8 @@ def main(arguments: list[str]) -> int:
|
|
|
644
743
|
result = prerequisites()
|
|
645
744
|
if journal is not None:
|
|
646
745
|
journal.emit(PRIORITIES["info"], "persistent prerequisites checked")
|
|
746
|
+
elif action == "ordinary-fallback":
|
|
747
|
+
result = ordinary_fallback(request)
|
|
647
748
|
elif action == "enable-linger":
|
|
648
749
|
result = enable_linger()
|
|
649
750
|
elif action == "ensure":
|
|
@@ -661,7 +762,15 @@ def main(arguments: list[str]) -> int:
|
|
|
661
762
|
return 0
|
|
662
763
|
except AgentError as error:
|
|
663
764
|
if journal is not None:
|
|
664
|
-
journal.emit(
|
|
765
|
+
journal.emit(
|
|
766
|
+
PRIORITIES[
|
|
767
|
+
"warning"
|
|
768
|
+
if action == "probe"
|
|
769
|
+
and error.code == "persistent_systemd_unavailable"
|
|
770
|
+
else "error"
|
|
771
|
+
],
|
|
772
|
+
f"{error.code}: {error}",
|
|
773
|
+
)
|
|
665
774
|
if not serving:
|
|
666
775
|
print(json.dumps({"error": error.code, "message": str(error)}), flush=True)
|
|
667
776
|
except JournalError:
|
|
@@ -10,6 +10,7 @@ import asyncio
|
|
|
10
10
|
import hashlib
|
|
11
11
|
import shutil
|
|
12
12
|
import signal
|
|
13
|
+
import subprocess
|
|
13
14
|
import sys
|
|
14
15
|
from collections.abc import Sequence
|
|
15
16
|
from contextlib import suppress
|
|
@@ -36,7 +37,15 @@ from .config import (
|
|
|
36
37
|
SUPPORTED_NETWORK_PROFILES,
|
|
37
38
|
XpraConfig,
|
|
38
39
|
)
|
|
39
|
-
from .
|
|
40
|
+
from .desktop import NOTIFICATION_PACKAGES, probe_argv, probe_result
|
|
41
|
+
from .journal import (
|
|
42
|
+
DEFAULT_LOG_LEVEL,
|
|
43
|
+
LOG_LEVELS,
|
|
44
|
+
PRIORITIES,
|
|
45
|
+
Journal,
|
|
46
|
+
JournalError,
|
|
47
|
+
xpra_environment,
|
|
48
|
+
)
|
|
40
49
|
from .session import ReportedSSHError, XpraSession
|
|
41
50
|
from .xpra_runtime import XpraRuntimeError, prepare, prepared_launcher
|
|
42
51
|
|
|
@@ -66,6 +75,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
66
75
|
authority.add_argument("--host", help="direct SSH host or address")
|
|
67
76
|
parser.add_argument("--user", help="remote user required with --host")
|
|
68
77
|
parser.add_argument("--port", type=int, default=22, help="direct SSH port")
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--env",
|
|
80
|
+
action="append",
|
|
81
|
+
metavar="NAME[=VALUE]",
|
|
82
|
+
help="set an application variable, or copy a named local variable; repeat for multiple variables",
|
|
83
|
+
)
|
|
69
84
|
parser.add_argument(
|
|
70
85
|
"--encoding-profile",
|
|
71
86
|
choices=SUPPORTED_ENCODING_PROFILES,
|
|
@@ -90,7 +105,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
90
105
|
parser.add_argument(
|
|
91
106
|
"--persistent",
|
|
92
107
|
action="store_true",
|
|
93
|
-
help="
|
|
108
|
+
help="request persistence and resume by argv; warn and use ordinary lifetime if unavailable",
|
|
94
109
|
)
|
|
95
110
|
parser.add_argument(
|
|
96
111
|
"--log-level",
|
|
@@ -136,6 +151,7 @@ def _diagnose() -> int:
|
|
|
136
151
|
("_persistent_agent.py", files("elsewindow").joinpath("_persistent_agent.py")),
|
|
137
152
|
("journal.py", files("elsewindow").joinpath("journal.py")),
|
|
138
153
|
("session_bus.py", files("elsewindow").joinpath("session_bus.py")),
|
|
154
|
+
("desktop.py", files("elsewindow").joinpath("desktop.py")),
|
|
139
155
|
("log_transport.py", files("elsewindow").joinpath("log_transport.py")),
|
|
140
156
|
(
|
|
141
157
|
"requirements-xpra.txt",
|
|
@@ -185,9 +201,58 @@ def _diagnose() -> int:
|
|
|
185
201
|
failed = True
|
|
186
202
|
else:
|
|
187
203
|
print("xpra-environment: verified")
|
|
204
|
+
if not failed:
|
|
205
|
+
_diagnose_optional()
|
|
188
206
|
return int(failed)
|
|
189
207
|
|
|
190
208
|
|
|
209
|
+
def _diagnose_optional() -> None:
|
|
210
|
+
try:
|
|
211
|
+
result = subprocess.run(
|
|
212
|
+
probe_argv("client"),
|
|
213
|
+
stdin=subprocess.DEVNULL,
|
|
214
|
+
capture_output=True,
|
|
215
|
+
env=xpra_environment(DEFAULT_LOG_LEVEL),
|
|
216
|
+
timeout=15,
|
|
217
|
+
check=False,
|
|
218
|
+
)
|
|
219
|
+
capabilities = probe_result(result.returncode, result.stdout)
|
|
220
|
+
except (OSError, ValueError, subprocess.TimeoutExpired):
|
|
221
|
+
capabilities = {"notifications": False, "render": False}
|
|
222
|
+
for feature, available in capabilities.items():
|
|
223
|
+
print(
|
|
224
|
+
f"optional-local-{feature}: {'available' if available else 'unavailable'}"
|
|
225
|
+
)
|
|
226
|
+
if not capabilities["notifications"]:
|
|
227
|
+
print(
|
|
228
|
+
"elsewindow: warning: local notification delivery is unavailable. "
|
|
229
|
+
f"On Debian/Ubuntu, install {NOTIFICATION_PACKAGES} and run a desktop "
|
|
230
|
+
"notification service (for example dunst). Sessions continue without delivery.",
|
|
231
|
+
file=sys.stderr,
|
|
232
|
+
)
|
|
233
|
+
if not capabilities["render"]:
|
|
234
|
+
print(
|
|
235
|
+
"elsewindow: warning: no accessible local DRM render device; H.264 "
|
|
236
|
+
"connections fall back to RGB. Check GPU permissions and the system "
|
|
237
|
+
"VA-API driver. Ordinary RGB sessions remain available.",
|
|
238
|
+
file=sys.stderr,
|
|
239
|
+
)
|
|
240
|
+
journal = Journal(DEFAULT_LOG_LEVEL, "client")
|
|
241
|
+
try:
|
|
242
|
+
journal.open()
|
|
243
|
+
except (OSError, JournalError):
|
|
244
|
+
print(
|
|
245
|
+
"elsewindow: warning: local journald is unavailable. Install systemd "
|
|
246
|
+
"and enable systemd-journald; terminal logging remains available.",
|
|
247
|
+
file=sys.stderr,
|
|
248
|
+
)
|
|
249
|
+
finally:
|
|
250
|
+
journal.close()
|
|
251
|
+
print(
|
|
252
|
+
"optional-remote-features: checked after SSH authentication and display startup"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
191
256
|
async def _run_with_signals(config: XpraConfig) -> int:
|
|
192
257
|
session = XpraSession(config)
|
|
193
258
|
loop = asyncio.get_running_loop()
|
|
@@ -234,9 +299,10 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
234
299
|
or args.user is not None
|
|
235
300
|
or args.port != 22
|
|
236
301
|
or args.application
|
|
302
|
+
or args.env
|
|
237
303
|
):
|
|
238
304
|
parser.error(
|
|
239
|
-
"setup and diagnosis cannot be combined with
|
|
305
|
+
"setup and diagnosis cannot be combined with session or application inputs"
|
|
240
306
|
)
|
|
241
307
|
if args.diagnose:
|
|
242
308
|
return _diagnose()
|