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.
Files changed (40) hide show
  1. elsewindow-0.2.1/.version +1 -0
  2. {elsewindow-0.2.0 → elsewindow-0.2.1}/CHANGELOG.md +73 -0
  3. {elsewindow-0.2.0 → elsewindow-0.2.1}/PKG-INFO +17 -10
  4. {elsewindow-0.2.0 → elsewindow-0.2.1}/README.md +15 -8
  5. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/_persistent_agent.py +146 -37
  6. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/cli.py +69 -3
  7. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/config.py +24 -0
  8. elsewindow-0.2.1/elsewindow/desktop.py +480 -0
  9. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/journal.py +129 -26
  10. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/live-cli.yml +3 -1
  11. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/persistent.py +102 -10
  12. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/requirements-xpra-build.in +1 -1
  13. elsewindow-0.2.1/elsewindow/requirements-xpra-build.txt +121 -0
  14. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/session.py +168 -32
  15. elsewindow-0.2.1/elsewindow/session_bus.py +161 -0
  16. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/xpra_runtime.py +52 -19
  17. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow.egg-info/PKG-INFO +17 -10
  18. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow.egg-info/SOURCES.txt +1 -0
  19. elsewindow-0.2.1/elsewindow.egg-info/requires.txt +1 -0
  20. {elsewindow-0.2.0 → elsewindow-0.2.1}/requirements.in +1 -1
  21. elsewindow-0.2.0/.version +0 -1
  22. elsewindow-0.2.0/elsewindow/requirements-xpra-build.txt +0 -121
  23. elsewindow-0.2.0/elsewindow/session_bus.py +0 -93
  24. elsewindow-0.2.0/elsewindow.egg-info/requires.txt +0 -1
  25. {elsewindow-0.2.0 → elsewindow-0.2.1}/LICENSE +0 -0
  26. {elsewindow-0.2.0 → elsewindow-0.2.1}/MANIFEST.in +0 -0
  27. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/__init__.py +0 -0
  28. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/__main__.py +0 -0
  29. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/live_config.py +0 -0
  30. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/log_transport.py +0 -0
  31. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/machine.py +0 -0
  32. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/profiles.yml +0 -0
  33. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/py.typed +0 -0
  34. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/requirements-xpra.in +0 -0
  35. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow/requirements-xpra.txt +0 -0
  36. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow.egg-info/dependency_links.txt +0 -0
  37. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow.egg-info/entry_points.txt +0 -0
  38. {elsewindow-0.2.0 → elsewindow-0.2.1}/elsewindow.egg-info/top_level.txt +0 -0
  39. {elsewindow-0.2.0 → elsewindow-0.2.1}/pyproject.toml +0 -0
  40. {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.0
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.0
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 and an
46
- accessible system journal on both systems. Install with pip or pipx after the
47
- first release:
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 "elsewindow==0.2.0"
51
- # or: pipx install "elsewindow==0.2.0"
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==0.1.0`](https://pypi.org/project/ssh-wrapper/0.1.0/) dependency.
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. Reviewed profile arguments come from the packaged YAML mirrors.
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 and an
16
- accessible system journal on both systems. Install with pip or pipx after the
17
- first release:
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 "elsewindow==0.2.0"
21
- # or: pipx install "elsewindow==0.2.0"
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==0.1.0`](https://pypi.org/project/ssh-wrapper/0.1.0/) dependency.
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. Reviewed profile arguments come from the packaged YAML mirrors.
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 = "import base64,json,os,sys;a=json.loads(base64.b64decode(sys.argv[1]));os.execv(a[0],a)"
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
- linger = linger_enabled()
139
- manager = False
140
- if linger:
141
- runtime_directory()
142
- manager = (
143
- run("/usr/bin/systemctl", "--user", "show-environment").returncode == 0
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 canonical_application(value: Any) -> list[str]:
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 = shutil.which(executable) or ""
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
- application = canonical_application(request["application"])
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 = request["server"]
376
- if not isinstance(server, list) or not all(isinstance(arg, str) for arg in server):
377
- raise AgentError("invalid_configuration", "invalid persistent server command")
378
- server = [arg.replace("$XDG_RUNTIME_DIR", str(root.parent)) for arg in server]
379
- child = shlex.join((sys.executable, "-c", APP_LOADER, encode(application)))
380
- indexes = [i for i, arg in enumerate(server) if arg.startswith("--start-child=")]
381
- if len(indexes) != 1:
382
- raise AgentError(
383
- "invalid_configuration", "persistent server has no unique child command"
384
- )
385
- server[indexes[0]] = f"--start-child={child}"
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") != 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.open()
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.start(xpra_environment(journal.level)),
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
- if action in {
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.open()
732
+ journal.open_optional()
638
733
  if action == "identify":
639
- key = digest(canonical_application(request["application"]))
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(PRIORITIES["error"], f"{error.code}: {error}")
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 .journal import DEFAULT_LOG_LEVEL, LOG_LEVELS, PRIORITIES, Journal
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="keep the remote application after disconnect and resume by its argv",
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 a session authority"
305
+ "setup and diagnosis cannot be combined with session or application inputs"
240
306
  )
241
307
  if args.diagnose:
242
308
  return _diagnose()