cdx-manager 0.17.1 → 0.18.1
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.
- package/README.md +50 -1
- package/changelogs/CHANGELOGS_0_18_0.md +82 -0
- package/changelogs/CHANGELOGS_0_18_1.md +50 -0
- package/checksums/release-archives.json +14 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +31 -2
- package/src/cli.py +8 -0
- package/src/cli_args.py +1 -0
- package/src/cli_commands.py +3 -0
- package/src/cli_hints.py +167 -0
- package/src/commands/status.py +3 -0
- package/src/commands/tray.py +445 -0
- package/src/notify.py +24 -4
- package/src/tray_autostart.py +108 -0
- package/src/tray_capability.py +120 -0
- package/src/tray_contract.py +229 -0
- package/src/tray_events.py +210 -0
- package/src/tray_install.py +383 -0
- package/src/tray_instance.py +57 -0
- package/src/tray_shortcut.py +223 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""Tray domain commands: the `cdx tray` surface a companion talks to.
|
|
2
|
+
|
|
3
|
+
`status` is what the native companion reads. `install`, `launch`, `uninstall`
|
|
4
|
+
and `autostart` act on recorded state and say plainly when there is none, and
|
|
5
|
+
`doctor` reads all of it without changing any of it.
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
from ..cli_args import TRAY_USAGE, _parse_flag_args
|
|
12
|
+
from ..cli_helpers import _json_success, _write_json
|
|
13
|
+
from ..cli_render import _dim, _pad_table, _style, _warn
|
|
14
|
+
from ..errors import CdxError
|
|
15
|
+
from ..tray_autostart import disable as autostart_disable
|
|
16
|
+
from ..tray_autostart import enable as autostart_enable
|
|
17
|
+
from ..tray_autostart import status as autostart_status
|
|
18
|
+
from ..tray_capability import desktop_capability, toast_capability
|
|
19
|
+
from ..tray_contract import (
|
|
20
|
+
AUTH_LOCKED,
|
|
21
|
+
FRESH,
|
|
22
|
+
UNKNOWN,
|
|
23
|
+
build_snapshot,
|
|
24
|
+
)
|
|
25
|
+
from ..tray_events import (
|
|
26
|
+
acknowledge as ack_events,
|
|
27
|
+
)
|
|
28
|
+
from ..tray_events import (
|
|
29
|
+
read_events,
|
|
30
|
+
write_heartbeat,
|
|
31
|
+
)
|
|
32
|
+
from ..tray_install import (
|
|
33
|
+
TrayInstallError,
|
|
34
|
+
companion_path,
|
|
35
|
+
current_target,
|
|
36
|
+
install,
|
|
37
|
+
interrupted_update,
|
|
38
|
+
launch_command,
|
|
39
|
+
read_state,
|
|
40
|
+
uninstall,
|
|
41
|
+
)
|
|
42
|
+
from ..tray_instance import companion_instance
|
|
43
|
+
|
|
44
|
+
TRAY_ACTIONS = ("status", "install", "launch", "uninstall", "autostart", "doctor", "events", "ack", "heartbeat")
|
|
45
|
+
|
|
46
|
+
# Codes rather than prose, so a caller can branch on them.
|
|
47
|
+
COMPANION_UNAVAILABLE = "tray_companion_not_available"
|
|
48
|
+
COMPANION_NOT_INSTALLED = "tray_companion_not_installed"
|
|
49
|
+
COMPANION_MISSING_FILE = "tray_companion_missing"
|
|
50
|
+
AUTOSTART_UNSUPPORTED = "tray_autostart_unsupported"
|
|
51
|
+
|
|
52
|
+
_FRESHNESS_TEXT = {
|
|
53
|
+
FRESH: "fresh",
|
|
54
|
+
AUTH_LOCKED: "cannot refresh while the session runs",
|
|
55
|
+
UNKNOWN: "never reported",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def handle_tray(rest, ctx):
|
|
60
|
+
if not rest or rest[0] in ("help", "--help", "-h"):
|
|
61
|
+
ctx["out"](f"{TRAY_USAGE}\n")
|
|
62
|
+
return 0
|
|
63
|
+
action, *args = rest
|
|
64
|
+
if action not in TRAY_ACTIONS:
|
|
65
|
+
raise CdxError(TRAY_USAGE)
|
|
66
|
+
if action == "status":
|
|
67
|
+
return _tray_status(args, ctx)
|
|
68
|
+
if action == "launch":
|
|
69
|
+
return _tray_launch(args, ctx)
|
|
70
|
+
if action == "uninstall":
|
|
71
|
+
return _tray_uninstall(args, ctx)
|
|
72
|
+
if action == "autostart":
|
|
73
|
+
return _tray_autostart(args, ctx)
|
|
74
|
+
if action == "doctor":
|
|
75
|
+
return _tray_doctor(args, ctx)
|
|
76
|
+
if action == "events":
|
|
77
|
+
return _tray_events(args, ctx)
|
|
78
|
+
if action == "ack":
|
|
79
|
+
return _tray_ack(args, ctx)
|
|
80
|
+
if action == "heartbeat":
|
|
81
|
+
return _tray_heartbeat(args, ctx)
|
|
82
|
+
return _tray_install(args, ctx)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _tray_status(args, ctx):
|
|
86
|
+
parsed = _parse_flag_args(args, {
|
|
87
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
88
|
+
"--refresh": {"key": "refresh", "type": "bool", "default": False},
|
|
89
|
+
"--beat": {"key": "beat", "type": "bool", "default": False},
|
|
90
|
+
}, TRAY_USAGE, positionals_key="args", max_positionals=0)
|
|
91
|
+
|
|
92
|
+
# Cached by default: a tray polls, and a live probe would contend on the
|
|
93
|
+
# Codex auth lock and can invalidate a rotating refresh token. Only an
|
|
94
|
+
# explicit refresh, which is a user gesture from the menu, may probe.
|
|
95
|
+
rows = ctx["service"]["get_status_rows"](
|
|
96
|
+
force_refresh=parsed["refresh"],
|
|
97
|
+
cache_only=not parsed["refresh"],
|
|
98
|
+
)
|
|
99
|
+
snapshot = build_snapshot(
|
|
100
|
+
rows,
|
|
101
|
+
datetime.now(timezone.utc),
|
|
102
|
+
ctx["version"],
|
|
103
|
+
refreshable=not any(row.get("active") for row in rows),
|
|
104
|
+
)
|
|
105
|
+
# Pending alerts ride along with the snapshot rather than costing their own
|
|
106
|
+
# call. Across WSL every invocation is a `wsl.exe` crossing measured at
|
|
107
|
+
# 371-401 ms, so a companion asking three times per poll would triple the
|
|
108
|
+
# idle cost adr_005 fixed a budget for. One crossing, everything the tray
|
|
109
|
+
# needs.
|
|
110
|
+
base_dir = ctx["service"]["base_dir"]
|
|
111
|
+
snapshot["events"] = read_events(base_dir)
|
|
112
|
+
# `--beat` is what claims ownership of alerts, so it is opt-in: a caller
|
|
113
|
+
# that merely looks at the status must not make `cdx notify` believe a tray
|
|
114
|
+
# is listening.
|
|
115
|
+
if parsed["beat"]:
|
|
116
|
+
write_heartbeat(base_dir)
|
|
117
|
+
if parsed["json"]:
|
|
118
|
+
_write_json(ctx, _json_success("tray.status", "Built tray snapshot", snapshot=snapshot))
|
|
119
|
+
return 0
|
|
120
|
+
ctx["out"](f"{_format_snapshot(snapshot, use_color=ctx['use_color'])}\n")
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _tray_launch(args, ctx):
|
|
125
|
+
parsed = _parse_flag_args(args, {
|
|
126
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
127
|
+
}, TRAY_USAGE, positionals_key="args", max_positionals=0)
|
|
128
|
+
base_dir = ctx["service"]["base_dir"]
|
|
129
|
+
executable, source = companion_path(base_dir, env=ctx.get("env"))
|
|
130
|
+
|
|
131
|
+
if not executable:
|
|
132
|
+
return _refuse(
|
|
133
|
+
ctx, parsed["json"], "tray.launch", COMPANION_NOT_INSTALLED,
|
|
134
|
+
"No tray companion is installed. Run: cdx tray install",
|
|
135
|
+
)
|
|
136
|
+
if not os.path.exists(executable):
|
|
137
|
+
# A recorded path that no longer exists is worth naming: the companion
|
|
138
|
+
# was removed behind CDX's back, and reinstalling is the fix.
|
|
139
|
+
return _refuse(
|
|
140
|
+
ctx, parsed["json"], "tray.launch", COMPANION_MISSING_FILE,
|
|
141
|
+
f"The recorded tray companion is gone: {executable}. Run: cdx tray install",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
command = launch_command(executable)
|
|
145
|
+
try:
|
|
146
|
+
# Detached on purpose: the tray outlives the command that started it,
|
|
147
|
+
# and cdx must not wait on a process that only exits when the user quits.
|
|
148
|
+
spawn = ctx.get("spawn_detached") or _spawn_detached
|
|
149
|
+
spawn(command)
|
|
150
|
+
except OSError as error:
|
|
151
|
+
raise CdxError(f"Could not start the tray companion: {error}") from error
|
|
152
|
+
|
|
153
|
+
message = f"Started the tray companion from {executable}"
|
|
154
|
+
if parsed["json"]:
|
|
155
|
+
_write_json(ctx, _json_success(
|
|
156
|
+
"tray.launch", message, executable=executable, source=source, command=command,
|
|
157
|
+
))
|
|
158
|
+
return 0
|
|
159
|
+
ctx["out"](f"{message}\n")
|
|
160
|
+
return 0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _spawn_detached(command):
|
|
164
|
+
subprocess.Popen( # noqa: S603 (command is CDX-controlled, never user text)
|
|
165
|
+
command,
|
|
166
|
+
stdin=subprocess.DEVNULL,
|
|
167
|
+
stdout=subprocess.DEVNULL,
|
|
168
|
+
stderr=subprocess.DEVNULL,
|
|
169
|
+
start_new_session=True,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _tray_uninstall(args, ctx):
|
|
174
|
+
parsed = _parse_flag_args(args, {
|
|
175
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
176
|
+
}, TRAY_USAGE, positionals_key="args", max_positionals=0)
|
|
177
|
+
result = uninstall(ctx["service"]["base_dir"])
|
|
178
|
+
if not result:
|
|
179
|
+
# Nothing recorded means nothing to remove. Deleting on a guess is how
|
|
180
|
+
# an uninstaller takes a file it did not put there.
|
|
181
|
+
return _refuse(
|
|
182
|
+
ctx, parsed["json"], "tray.uninstall", COMPANION_NOT_INSTALLED,
|
|
183
|
+
"No tray companion install is recorded, so nothing was removed.",
|
|
184
|
+
)
|
|
185
|
+
message = f"Removed the tray companion ({len(result['removed'])} path(s))"
|
|
186
|
+
if parsed["json"]:
|
|
187
|
+
_write_json(ctx, _json_success(
|
|
188
|
+
"tray.uninstall", message, removed=result["removed"], applied=True,
|
|
189
|
+
))
|
|
190
|
+
return 0
|
|
191
|
+
ctx["out"](f"{message}\n")
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _tray_install(args, ctx):
|
|
196
|
+
parsed = _parse_flag_args(args, {
|
|
197
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
198
|
+
}, TRAY_USAGE, positionals_key="args", max_positionals=0)
|
|
199
|
+
target = current_target()
|
|
200
|
+
if not target:
|
|
201
|
+
return _refuse(
|
|
202
|
+
ctx, parsed["json"], "tray.install", COMPANION_UNAVAILABLE,
|
|
203
|
+
"No tray companion is published for this operating system and architecture.",
|
|
204
|
+
)
|
|
205
|
+
try:
|
|
206
|
+
state = install(
|
|
207
|
+
ctx["service"]["base_dir"],
|
|
208
|
+
ctx["version"],
|
|
209
|
+
download=ctx.get("download_asset"),
|
|
210
|
+
env=ctx.get("env"),
|
|
211
|
+
)
|
|
212
|
+
except TrayInstallError as error:
|
|
213
|
+
# A refusal, not a crash: nothing was written, and the reason is the
|
|
214
|
+
# useful part. Raising keeps the non-zero exit a caller expects.
|
|
215
|
+
raise CdxError(str(error)) from error
|
|
216
|
+
|
|
217
|
+
message = f"Installed the {state['target']} tray companion for CDX {state['cdx_version']}"
|
|
218
|
+
if parsed["json"]:
|
|
219
|
+
_write_json(ctx, _json_success(
|
|
220
|
+
"tray.install", message,
|
|
221
|
+
target=state["target"], executable=state["executable"],
|
|
222
|
+
sha256=state["sha256"], applied=True,
|
|
223
|
+
))
|
|
224
|
+
return 0
|
|
225
|
+
ctx["out"](f"{message}\n{_dim(state['executable'], ctx['use_color'])}\n")
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _tray_autostart(args, ctx):
|
|
230
|
+
parsed = _parse_flag_args(args, {
|
|
231
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
232
|
+
}, TRAY_USAGE, positionals_key="args", max_positionals=1)
|
|
233
|
+
mode = (parsed["args"] or ["status"])[0]
|
|
234
|
+
if mode not in ("on", "off", "status"):
|
|
235
|
+
raise CdxError(TRAY_USAGE)
|
|
236
|
+
env = ctx.get("env")
|
|
237
|
+
|
|
238
|
+
if mode != "status":
|
|
239
|
+
executable, _source = companion_path(ctx["service"]["base_dir"], env=env)
|
|
240
|
+
if mode == "on" and not executable:
|
|
241
|
+
return _refuse(
|
|
242
|
+
ctx, parsed["json"], "tray.autostart", COMPANION_NOT_INSTALLED,
|
|
243
|
+
"No tray companion is installed, so there is nothing to start at login.",
|
|
244
|
+
)
|
|
245
|
+
try:
|
|
246
|
+
if mode == "on":
|
|
247
|
+
autostart_enable(executable, env=env, run=ctx.get("spawn_sync"))
|
|
248
|
+
else:
|
|
249
|
+
autostart_disable(env=env, run=ctx.get("spawn_sync"))
|
|
250
|
+
except NotImplementedError as error:
|
|
251
|
+
return _refuse(
|
|
252
|
+
ctx, parsed["json"], "tray.autostart", AUTOSTART_UNSUPPORTED, str(error),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# Read the platform back rather than reporting what we just intended: a
|
|
256
|
+
# recorded intention that drifted from the system is the confusion doctor
|
|
257
|
+
# exists to end.
|
|
258
|
+
state = autostart_status(env=env, run=ctx.get("spawn_sync"))
|
|
259
|
+
if not state["supported"]:
|
|
260
|
+
return _refuse(
|
|
261
|
+
ctx, parsed["json"], "tray.autostart", AUTOSTART_UNSUPPORTED,
|
|
262
|
+
"This platform has no autostart mechanism CDX knows how to manage.",
|
|
263
|
+
)
|
|
264
|
+
message = f"Tray autostart is {'on' if state['enabled'] else 'off'}"
|
|
265
|
+
if parsed["json"]:
|
|
266
|
+
_write_json(ctx, _json_success(
|
|
267
|
+
"tray.autostart", message,
|
|
268
|
+
enabled=state["enabled"], artifact=state["artifact"], applied=mode != "status",
|
|
269
|
+
))
|
|
270
|
+
return 0
|
|
271
|
+
ctx["out"](f"{message}\n{_dim(state['artifact'], ctx['use_color'])}\n")
|
|
272
|
+
return 0
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# `None` is a real answer here: the bus could not be asked. Reporting it as
|
|
276
|
+
# "no tray" would be a claim the probe did not make.
|
|
277
|
+
_DESKTOP_STATE = {True: "ready", False: "no tray", None: "unknown"}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _tray_doctor(args, ctx):
|
|
281
|
+
"""Every state I had to diagnose by hand, in one bounded report.
|
|
282
|
+
|
|
283
|
+
No background service and no repair: it reads, it never fixes. A doctor that
|
|
284
|
+
also acted would have to be trusted with the machine, and this one only has
|
|
285
|
+
to be trusted to tell the truth.
|
|
286
|
+
"""
|
|
287
|
+
parsed = _parse_flag_args(args, {
|
|
288
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
289
|
+
}, TRAY_USAGE, positionals_key="args", max_positionals=0)
|
|
290
|
+
base_dir = ctx["service"]["base_dir"]
|
|
291
|
+
env = ctx.get("env")
|
|
292
|
+
executable, source = companion_path(base_dir, env=env)
|
|
293
|
+
state = read_state(base_dir)
|
|
294
|
+
autostart = autostart_status(env=env, run=ctx.get("spawn_sync"))
|
|
295
|
+
instance = companion_instance()
|
|
296
|
+
desktop = desktop_capability(env=env)
|
|
297
|
+
toasts = toast_capability(env=env)
|
|
298
|
+
|
|
299
|
+
checks = [
|
|
300
|
+
("companion", "installed" if state else ("override" if executable else "absent"),
|
|
301
|
+
executable or "run: cdx tray install"),
|
|
302
|
+
("executable", "present" if executable and os.path.exists(executable) else "missing",
|
|
303
|
+
executable or "-"),
|
|
304
|
+
("cdx_version", ctx["version"], (state or {}).get("cdx_version") or "-"),
|
|
305
|
+
("target", (state or {}).get("target") or "-", (state or {}).get("sha256") or "-"),
|
|
306
|
+
("running", "yes" if instance.get("pid") else "no", str(instance.get("pid") or "-")),
|
|
307
|
+
("autostart", "on" if autostart["enabled"] else "off", autostart["artifact"] or "unsupported"),
|
|
308
|
+
("update", "interrupted" if interrupted_update(base_dir) else "clean",
|
|
309
|
+
"a staged companion was never promoted; run: cdx tray install"
|
|
310
|
+
if interrupted_update(base_dir) else "-"),
|
|
311
|
+
# Both fail silently when absent, which is the only reason they are
|
|
312
|
+
# worth a line: nothing else would ever tell the user.
|
|
313
|
+
("desktop", _DESKTOP_STATE.get(desktop.get("available")), desktop["detail"]),
|
|
314
|
+
("toasts", "ready" if toasts["present"] else ("blocked" if toasts["gated"] else "n/a"),
|
|
315
|
+
toasts["detail"]),
|
|
316
|
+
]
|
|
317
|
+
if parsed["json"]:
|
|
318
|
+
_write_json(ctx, _json_success(
|
|
319
|
+
"tray.doctor", "Collected tray diagnostics",
|
|
320
|
+
checks=[{"check": c, "state": s, "detail": d} for c, s, d in checks],
|
|
321
|
+
))
|
|
322
|
+
return 0
|
|
323
|
+
rows = [["CHECK", "STATE", "DETAIL"], *[[c, s, d] for c, s, d in checks]]
|
|
324
|
+
ctx["out"](f"{_pad_table(rows)}\n")
|
|
325
|
+
return 0
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _refuse(ctx, as_json, action, code, message):
|
|
329
|
+
"""Report that nothing happened, and why, without failing the command.
|
|
330
|
+
|
|
331
|
+
Not an error: asking to launch a tray that is not installed is a reasonable
|
|
332
|
+
thing to do, and the answer is a state rather than a fault.
|
|
333
|
+
"""
|
|
334
|
+
if as_json:
|
|
335
|
+
_write_json(ctx, _json_success(
|
|
336
|
+
action, message, warnings=[{"code": code, "message": message}], applied=False,
|
|
337
|
+
))
|
|
338
|
+
return 0
|
|
339
|
+
ctx["out"](f"{_warn(message, ctx['use_color'])}\n")
|
|
340
|
+
return 0
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _companion_action(action, args, ctx):
|
|
344
|
+
parsed = _parse_flag_args(args, {
|
|
345
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
346
|
+
}, TRAY_USAGE, positionals_key="args", max_positionals=0)
|
|
347
|
+
message = (
|
|
348
|
+
f"cdx tray {action} is defined but no tray companion ships with CDX {ctx['version']}. "
|
|
349
|
+
"Nothing was installed, started, or changed."
|
|
350
|
+
)
|
|
351
|
+
if parsed["json"]:
|
|
352
|
+
_write_json(ctx, _json_success(
|
|
353
|
+
f"tray.{action}",
|
|
354
|
+
message,
|
|
355
|
+
warnings=[{"code": COMPANION_UNAVAILABLE, "message": message}],
|
|
356
|
+
applied=False,
|
|
357
|
+
))
|
|
358
|
+
return 0
|
|
359
|
+
ctx["out"](f"{_warn(message, ctx['use_color'])}\n")
|
|
360
|
+
return 0
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _pct(value):
|
|
364
|
+
return "?" if value is None else f"{int(value)}%"
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _format_snapshot(snapshot, use_color=False):
|
|
368
|
+
icon = snapshot["icon"]
|
|
369
|
+
header = _style(f"tray: {icon['state']}", "1", use_color)
|
|
370
|
+
if not snapshot["sessions"]:
|
|
371
|
+
return f"{header}\n{_dim('No enabled sessions.', use_color)}"
|
|
372
|
+
rows = [["SESSION", "PROVIDER", "LEFT", "STATE", "DATA"]]
|
|
373
|
+
for session in snapshot["sessions"]:
|
|
374
|
+
rows.append([
|
|
375
|
+
session["name"] or "?",
|
|
376
|
+
session["provider"] or "-",
|
|
377
|
+
_pct(session["available_pct"]),
|
|
378
|
+
session["state"],
|
|
379
|
+
_FRESHNESS_TEXT.get(session["freshness"], session["freshness"]),
|
|
380
|
+
])
|
|
381
|
+
lines = [header, _pad_table(rows)]
|
|
382
|
+
if not snapshot["refreshable"]:
|
|
383
|
+
lines.append(_dim("A running session holds the provider lock; refresh will not update it.", use_color))
|
|
384
|
+
return "\n".join(lines)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# The companion reads and acknowledges events through these three commands
|
|
388
|
+
# rather than through the filesystem, and that is the whole point of them: on a
|
|
389
|
+
# Windows host serving CDX from WSL, the spool lives in the Linux filesystem
|
|
390
|
+
# while the tray runs on Windows. Going through `cdx` means the existing
|
|
391
|
+
# `wsl.exe` transport carries events too — no path translation, no share, no
|
|
392
|
+
# socket, and no assumption that either side can see the other's disk.
|
|
393
|
+
def _tray_events(args, ctx):
|
|
394
|
+
parsed = _parse_flag_args(args, {
|
|
395
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
396
|
+
}, TRAY_USAGE, positionals_key="args", max_positionals=0)
|
|
397
|
+
events = read_events(ctx["service"]["base_dir"])
|
|
398
|
+
if parsed["json"]:
|
|
399
|
+
_write_json(ctx, _json_success("tray.events", f"Read {len(events)} pending event(s)", events=events))
|
|
400
|
+
return 0
|
|
401
|
+
if not events:
|
|
402
|
+
ctx["out"]("No pending tray events.\n")
|
|
403
|
+
return 0
|
|
404
|
+
rows = [["WHEN", "KIND", "TITLE", "MESSAGE"]]
|
|
405
|
+
for event in events:
|
|
406
|
+
rows.append([
|
|
407
|
+
datetime.fromtimestamp(event.get("at", 0), timezone.utc).strftime("%H:%M:%S"),
|
|
408
|
+
event.get("kind", "-"), event.get("title", "-"), event.get("message", "-"),
|
|
409
|
+
])
|
|
410
|
+
ctx["out"](f"{_pad_table(rows)}\n")
|
|
411
|
+
return 0
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _tray_ack(args, ctx):
|
|
415
|
+
parsed = _parse_flag_args(args, {
|
|
416
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
417
|
+
}, TRAY_USAGE, positionals_key="args", max_positionals=64)
|
|
418
|
+
ids = parsed["args"]
|
|
419
|
+
if not ids:
|
|
420
|
+
raise CdxError(TRAY_USAGE)
|
|
421
|
+
result = ack_events(ctx["service"]["base_dir"], ids)
|
|
422
|
+
message = f"Acknowledged {result['acknowledged']} event(s)"
|
|
423
|
+
if parsed["json"]:
|
|
424
|
+
_write_json(ctx, _json_success("tray.ack", message, **result))
|
|
425
|
+
return 0
|
|
426
|
+
ctx["out"](f"{message}\n")
|
|
427
|
+
return 0
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _tray_heartbeat(args, ctx):
|
|
431
|
+
"""The companion saying it is alive, so `cdx notify` stops delivering directly.
|
|
432
|
+
|
|
433
|
+
Written by the tray once per poll. `cdx notify` publishes only while this is
|
|
434
|
+
fresh, so a companion that stops beating hands alerts straight back to the
|
|
435
|
+
direct path rather than swallowing them.
|
|
436
|
+
"""
|
|
437
|
+
parsed = _parse_flag_args(args, {
|
|
438
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
439
|
+
}, TRAY_USAGE, positionals_key="args", max_positionals=0)
|
|
440
|
+
write_heartbeat(ctx["service"]["base_dir"])
|
|
441
|
+
if parsed["json"]:
|
|
442
|
+
_write_json(ctx, _json_success("tray.heartbeat", "Recorded the tray heartbeat"))
|
|
443
|
+
return 0
|
|
444
|
+
ctx["out"]("Recorded the tray heartbeat.\n")
|
|
445
|
+
return 0
|
package/src/notify.py
CHANGED
|
@@ -155,9 +155,14 @@ def _run_notification_command(argv, spawn_sync, env):
|
|
|
155
155
|
pass
|
|
156
156
|
|
|
157
157
|
|
|
158
|
-
# Windows
|
|
159
|
-
#
|
|
160
|
-
#
|
|
158
|
+
# Windows shows a toast only on behalf of an application it knows, and it knows
|
|
159
|
+
# an application by a Start Menu shortcut carrying its AppUserModelID. An
|
|
160
|
+
# unknown identifier is dropped in silence — no error, no toast.
|
|
161
|
+
#
|
|
162
|
+
# PowerShell's own identifier is the fallback, and it is a real compromise: the
|
|
163
|
+
# toast then says "Windows PowerShell", and turning CDX alerts off in Settings
|
|
164
|
+
# means turning PowerShell off. It is used only while CDX has no shortcut of its
|
|
165
|
+
# own, which `cdx tray install` now writes.
|
|
161
166
|
_POWERSHELL_AUMID = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe"
|
|
162
167
|
|
|
163
168
|
_TOAST_SCRIPT = """
|
|
@@ -183,7 +188,7 @@ def _send_windows_notification(title, message, spawn_sync, env, launcher):
|
|
|
183
188
|
script = _TOAST_SCRIPT.format(
|
|
184
189
|
title=_escape_powershell(title),
|
|
185
190
|
message=_escape_powershell(message),
|
|
186
|
-
aumid=
|
|
191
|
+
aumid=_escape_powershell(windows_aumid(env)),
|
|
187
192
|
)
|
|
188
193
|
_run_notification_command(
|
|
189
194
|
[*launcher, "-NoProfile", "-NonInteractive", "-Command", script],
|
|
@@ -192,6 +197,21 @@ def _send_windows_notification(title, message, spawn_sync, env, launcher):
|
|
|
192
197
|
)
|
|
193
198
|
|
|
194
199
|
|
|
200
|
+
def windows_aumid(env=None):
|
|
201
|
+
"""CDX's own identifier when Windows can resolve it, PowerShell's otherwise.
|
|
202
|
+
|
|
203
|
+
The shortcut has to exist before the identifier means anything: Windows
|
|
204
|
+
resolves an AppUserModelID through the Start Menu, so claiming ours without
|
|
205
|
+
one would drop every toast in silence — strictly worse than a toast
|
|
206
|
+
attributed to PowerShell.
|
|
207
|
+
"""
|
|
208
|
+
from .tray_shortcut import APP_USER_MODEL_ID, shortcut_path
|
|
209
|
+
path = shortcut_path(env)
|
|
210
|
+
if path and os.path.isfile(path):
|
|
211
|
+
return APP_USER_MODEL_ID
|
|
212
|
+
return _POWERSHELL_AUMID
|
|
213
|
+
|
|
214
|
+
|
|
195
215
|
def _escape_powershell(value):
|
|
196
216
|
return str(value).replace("'", "''")
|
|
197
217
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Start the tray companion at login, or do not. Explicitly, either way.
|
|
2
|
+
|
|
3
|
+
`req_038` AC1 is the whole design here: installing must never enable startup,
|
|
4
|
+
and turning it on must be a separate, reversible, idempotent act that reports
|
|
5
|
+
what the platform actually holds rather than what CDX last intended.
|
|
6
|
+
|
|
7
|
+
One recorded artifact per platform, chosen for being the plainest thing that can
|
|
8
|
+
be deleted again: a LaunchAgent plist, the per-user Run key, an XDG autostart
|
|
9
|
+
entry. All three are per-user and need no administrator rights.
|
|
10
|
+
"""
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import plistlib
|
|
14
|
+
import subprocess
|
|
15
|
+
|
|
16
|
+
LABEL = "com.cdx.tray"
|
|
17
|
+
RUN_KEY = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run"
|
|
18
|
+
RUN_VALUE = "CDXTray"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _home(env):
|
|
22
|
+
return env.get("HOME") or os.path.expanduser("~")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def artifact_path(env=None, system=None):
|
|
26
|
+
"""Where this platform records "start at login", or None if unsupported."""
|
|
27
|
+
env = os.environ if env is None else env
|
|
28
|
+
system = system or platform.system()
|
|
29
|
+
if system == "Darwin":
|
|
30
|
+
return os.path.join(_home(env), "Library", "LaunchAgents", f"{LABEL}.plist")
|
|
31
|
+
if system == "Linux":
|
|
32
|
+
base = env.get("XDG_CONFIG_HOME") or os.path.join(_home(env), ".config")
|
|
33
|
+
return os.path.join(base, "autostart", "cdx-tray.desktop")
|
|
34
|
+
if system == "Windows":
|
|
35
|
+
# Not a path: the registry value is the artifact. Named so callers can
|
|
36
|
+
# report it, and so uninstall knows what to remove.
|
|
37
|
+
return f"{RUN_KEY}\\{RUN_VALUE}"
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def status(env=None, system=None, run=None):
|
|
42
|
+
"""What the platform holds right now, read back rather than remembered.
|
|
43
|
+
|
|
44
|
+
Reading the real state is the point: a recorded intention that drifted from
|
|
45
|
+
the system is exactly the confusion `cdx tray doctor` exists to end.
|
|
46
|
+
"""
|
|
47
|
+
env = os.environ if env is None else env
|
|
48
|
+
system = system or platform.system()
|
|
49
|
+
path = artifact_path(env=env, system=system)
|
|
50
|
+
if not path:
|
|
51
|
+
return {"supported": False, "enabled": False, "artifact": None}
|
|
52
|
+
if system == "Windows":
|
|
53
|
+
runner = run or _run
|
|
54
|
+
result = runner(["reg", "query", RUN_KEY, "/v", RUN_VALUE])
|
|
55
|
+
return {"supported": True, "enabled": result.returncode == 0, "artifact": path}
|
|
56
|
+
return {"supported": True, "enabled": os.path.exists(path), "artifact": path}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _run(command):
|
|
60
|
+
return subprocess.run(command, capture_output=True, text=True, check=False)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def enable(executable, env=None, system=None, run=None):
|
|
64
|
+
"""Idempotent by construction: writing the same artifact twice is a no-op."""
|
|
65
|
+
env = os.environ if env is None else env
|
|
66
|
+
system = system or platform.system()
|
|
67
|
+
path = artifact_path(env=env, system=system)
|
|
68
|
+
if not path:
|
|
69
|
+
raise NotImplementedError(f"No autostart mechanism for {system}.")
|
|
70
|
+
if system == "Windows":
|
|
71
|
+
runner = run or _run
|
|
72
|
+
runner(["reg", "add", RUN_KEY, "/v", RUN_VALUE, "/t", "REG_SZ", "/d", executable, "/f"])
|
|
73
|
+
return path
|
|
74
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
75
|
+
if system == "Darwin":
|
|
76
|
+
with open(path, "wb") as handle:
|
|
77
|
+
plistlib.dump({
|
|
78
|
+
"Label": LABEL,
|
|
79
|
+
"ProgramArguments": [executable],
|
|
80
|
+
"RunAtLoad": True,
|
|
81
|
+
# No KeepAlive: quitting the tray from its own menu must mean
|
|
82
|
+
# quit, not "be restarted immediately by launchd".
|
|
83
|
+
"KeepAlive": False,
|
|
84
|
+
}, handle)
|
|
85
|
+
else:
|
|
86
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
87
|
+
handle.write(
|
|
88
|
+
"[Desktop Entry]\nType=Application\nName=CDX Tray\n"
|
|
89
|
+
f"Exec={executable}\nX-GNOME-Autostart-enabled=true\n"
|
|
90
|
+
)
|
|
91
|
+
return path
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def disable(env=None, system=None, run=None):
|
|
95
|
+
"""Remove the artifact. Absent is success, not an error."""
|
|
96
|
+
env = os.environ if env is None else env
|
|
97
|
+
system = system or platform.system()
|
|
98
|
+
path = artifact_path(env=env, system=system)
|
|
99
|
+
if not path:
|
|
100
|
+
return False
|
|
101
|
+
if system == "Windows":
|
|
102
|
+
runner = run or _run
|
|
103
|
+
result = runner(["reg", "delete", RUN_KEY, "/v", RUN_VALUE, "/f"])
|
|
104
|
+
return result.returncode == 0
|
|
105
|
+
if os.path.exists(path):
|
|
106
|
+
os.remove(path)
|
|
107
|
+
return True
|
|
108
|
+
return False
|