cdx-manager 0.17.1 → 0.18.0
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/checksums/release-archives.json +4 -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 +370 -0
- package/src/tray_instance.py +57 -0
- package/src/tray_shortcut.py +223 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Where an installed tray companion lives, and what was recorded about it.
|
|
2
|
+
|
|
3
|
+
`cdx tray install` is item_081's job; this module owns the shape of what it
|
|
4
|
+
writes so `launch` and `uninstall` can already be truthful about a companion
|
|
5
|
+
that is or is not there. Uninstall must remove only what install recorded, so
|
|
6
|
+
the record is the contract, not a guess about where files might be.
|
|
7
|
+
"""
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import shutil
|
|
13
|
+
import stat
|
|
14
|
+
import subprocess
|
|
15
|
+
import tarfile
|
|
16
|
+
import tempfile
|
|
17
|
+
import urllib.request
|
|
18
|
+
|
|
19
|
+
from .tray_shortcut import create as create_shortcut
|
|
20
|
+
|
|
21
|
+
STATE_VERSION = 1
|
|
22
|
+
|
|
23
|
+
RELEASE_ASSET_URL = "https://github.com/AlexAgo83/cdx-manager/releases/download/v{version}/{asset}"
|
|
24
|
+
CHECKSUM_LEDGER = os.path.join(
|
|
25
|
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
26
|
+
"checksums",
|
|
27
|
+
"release-archives.json",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# One asset per OS and architecture. The companion is a native binary, so a
|
|
31
|
+
# wrong pick is not a degraded experience, it is a file that cannot execute.
|
|
32
|
+
TARGETS = {
|
|
33
|
+
("Darwin", "arm64"): "aarch64-apple-darwin",
|
|
34
|
+
("Darwin", "x86_64"): "x86_64-apple-darwin",
|
|
35
|
+
("Windows", "AMD64"): "x86_64-pc-windows-gnu",
|
|
36
|
+
("Linux", "x86_64"): "x86_64-unknown-linux-musl",
|
|
37
|
+
("Linux", "aarch64"): "aarch64-unknown-linux-musl",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TrayInstallError(Exception):
|
|
42
|
+
"""Refused before anything was written. Every raise site here is a gate."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def current_target(system=None, machine=None):
|
|
46
|
+
"""The release target for this machine, or None when there is no asset.
|
|
47
|
+
|
|
48
|
+
Reported rather than guessed: installing the wrong architecture produces a
|
|
49
|
+
binary that cannot run, which is a worse failure than saying so up front.
|
|
50
|
+
"""
|
|
51
|
+
key = (system or platform.system(), machine or platform.machine())
|
|
52
|
+
return TARGETS.get(key)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def asset_name(version, target):
|
|
56
|
+
return f"cdx-tray-{version}-{target}.tar.gz"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def expected_checksum(version, target, ledger_path=CHECKSUM_LEDGER):
|
|
60
|
+
"""The published sha256 for this asset, or None when none is recorded.
|
|
61
|
+
|
|
62
|
+
None is a refusal, not a fallback. An asset with no published checksum is
|
|
63
|
+
exactly the case this gate exists for.
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
with open(ledger_path, encoding="utf-8") as handle:
|
|
67
|
+
ledger = json.load(handle)
|
|
68
|
+
except (OSError, ValueError):
|
|
69
|
+
return None
|
|
70
|
+
release = (ledger.get("releases") or {}).get(f"v{version}") or {}
|
|
71
|
+
return (release.get("tray_assets") or {}).get(target)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _sha256(path):
|
|
75
|
+
digest = hashlib.sha256()
|
|
76
|
+
with open(path, "rb") as handle:
|
|
77
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
78
|
+
digest.update(chunk)
|
|
79
|
+
return digest.hexdigest()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _download(url, destination):
|
|
83
|
+
with urllib.request.urlopen(url, timeout=60) as response, open(destination, "wb") as handle:
|
|
84
|
+
shutil.copyfileobj(response, handle)
|
|
85
|
+
|
|
86
|
+
# Every path install writes goes in here, so removal never has to guess.
|
|
87
|
+
def state_dir(base_dir):
|
|
88
|
+
return os.path.join(base_dir, "tray")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def state_path(base_dir):
|
|
92
|
+
return os.path.join(state_dir(base_dir), "install.json")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def read_state(base_dir):
|
|
96
|
+
"""What install recorded, or None. A damaged record reads as absent.
|
|
97
|
+
|
|
98
|
+
Refusing to parse rather than guessing is the safe failure here: the record
|
|
99
|
+
drives deletion, and a half-understood one could remove the wrong path.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
with open(state_path(base_dir), encoding="utf-8") as handle:
|
|
103
|
+
state = json.load(handle)
|
|
104
|
+
except (OSError, ValueError):
|
|
105
|
+
return None
|
|
106
|
+
if not isinstance(state, dict) or state.get("version") != STATE_VERSION:
|
|
107
|
+
return None
|
|
108
|
+
return state
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def companion_path(base_dir, env=None):
|
|
112
|
+
"""The companion to launch, and where that answer came from.
|
|
113
|
+
|
|
114
|
+
`CDX_TRAY_BIN` wins so a locally built companion can be run before any
|
|
115
|
+
install path exists; that is how this was developed and tested.
|
|
116
|
+
"""
|
|
117
|
+
env = os.environ if env is None else env
|
|
118
|
+
override = (env.get("CDX_TRAY_BIN") or "").strip()
|
|
119
|
+
if override:
|
|
120
|
+
return override, "override"
|
|
121
|
+
state = read_state(base_dir)
|
|
122
|
+
if state and state.get("executable"):
|
|
123
|
+
return state["executable"], "installed"
|
|
124
|
+
return None, "absent"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def install(base_dir, version, download=None, ledger_path=CHECKSUM_LEDGER, target=None,
|
|
128
|
+
destination=None, record=True, env=None):
|
|
129
|
+
"""Fetch, verify, unpack, record. Every step before unpack is a refusal point.
|
|
130
|
+
|
|
131
|
+
The checksum is verified before a single byte is unpacked, because after
|
|
132
|
+
unpacking there is already something on disk to clean up and something that
|
|
133
|
+
might be executed. `adr_005` makes this gate load-bearing: the companion is
|
|
134
|
+
self-signed, so the published checksum is what vouches for it, not Apple and
|
|
135
|
+
not Microsoft.
|
|
136
|
+
|
|
137
|
+
Fetching through cdx rather than a browser is also what keeps the asset free
|
|
138
|
+
of macOS quarantine and Windows Mark-of-the-Web, so a download link must
|
|
139
|
+
never be offered as an alternative.
|
|
140
|
+
"""
|
|
141
|
+
target = target or current_target()
|
|
142
|
+
if not target:
|
|
143
|
+
raise TrayInstallError(
|
|
144
|
+
f"No tray companion is published for {platform.system()} {platform.machine()}."
|
|
145
|
+
)
|
|
146
|
+
expected = expected_checksum(version, target, ledger_path=ledger_path)
|
|
147
|
+
if not expected:
|
|
148
|
+
raise TrayInstallError(
|
|
149
|
+
f"No published checksum for the {target} tray companion of CDX {version}. "
|
|
150
|
+
"Refusing to install an asset nothing vouches for."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
asset = asset_name(version, target)
|
|
154
|
+
url = RELEASE_ASSET_URL.format(version=version, asset=asset)
|
|
155
|
+
fetch = download or _download
|
|
156
|
+
with tempfile.TemporaryDirectory(prefix="cdx-tray-") as scratch:
|
|
157
|
+
archive = os.path.join(scratch, asset)
|
|
158
|
+
try:
|
|
159
|
+
fetch(url, archive)
|
|
160
|
+
except OSError as error:
|
|
161
|
+
raise TrayInstallError(f"Could not download {url}: {error}") from error
|
|
162
|
+
|
|
163
|
+
actual = _sha256(archive)
|
|
164
|
+
if actual != expected:
|
|
165
|
+
raise TrayInstallError(
|
|
166
|
+
f"Checksum mismatch for {asset}.\n expected {expected}\n got {actual}\n"
|
|
167
|
+
"Nothing was installed."
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
destination = destination or os.path.join(state_dir(base_dir), "companion")
|
|
171
|
+
shutil.rmtree(destination, ignore_errors=True)
|
|
172
|
+
os.makedirs(destination, exist_ok=True)
|
|
173
|
+
installed = _extract(archive, destination)
|
|
174
|
+
|
|
175
|
+
executable = _executable_in(destination, installed)
|
|
176
|
+
if not executable:
|
|
177
|
+
shutil.rmtree(destination, ignore_errors=True)
|
|
178
|
+
raise TrayInstallError(f"{asset} contains no cdx-tray executable. Nothing was installed.")
|
|
179
|
+
|
|
180
|
+
# Best-effort, and after the companion is known to be on disk: the shortcut
|
|
181
|
+
# points at it. A failure here does not fail the install — the tray runs
|
|
182
|
+
# either way, only its toasts would go nowhere, and doctor reports that.
|
|
183
|
+
shortcut = create_shortcut(executable, env=env)
|
|
184
|
+
|
|
185
|
+
state = {
|
|
186
|
+
"version": STATE_VERSION,
|
|
187
|
+
"cdx_version": version,
|
|
188
|
+
"target": target,
|
|
189
|
+
"asset": asset,
|
|
190
|
+
"sha256": expected,
|
|
191
|
+
"executable": executable,
|
|
192
|
+
# Uninstall removes these and nothing else. A path CDX did not write
|
|
193
|
+
# must never end up here — which is why the shortcut is recorded only
|
|
194
|
+
# when it was actually created, and never merely because it exists.
|
|
195
|
+
"paths": [destination] + ([shortcut["path"]] if shortcut["created"] else []),
|
|
196
|
+
"shortcut": shortcut["path"] if shortcut["created"] else None,
|
|
197
|
+
}
|
|
198
|
+
if not record:
|
|
199
|
+
# Staging: the caller promotes it and writes the record itself, so a
|
|
200
|
+
# staged install never claims to be the installed one.
|
|
201
|
+
return state
|
|
202
|
+
os.makedirs(state_dir(base_dir), exist_ok=True)
|
|
203
|
+
with open(state_path(base_dir), "w", encoding="utf-8") as handle:
|
|
204
|
+
json.dump(state, handle, indent=2)
|
|
205
|
+
return state
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _extract(archive, destination):
|
|
209
|
+
"""Unpack, refusing any member that would escape the destination.
|
|
210
|
+
|
|
211
|
+
A tar entry may name `../` or an absolute path. Trusting the archive here
|
|
212
|
+
would let a bad asset write anywhere the user can, which is the one thing a
|
|
213
|
+
verified checksum does not protect against if verification is ever skipped.
|
|
214
|
+
"""
|
|
215
|
+
names = []
|
|
216
|
+
with tarfile.open(archive, "r:gz") as tar:
|
|
217
|
+
for member in tar.getmembers():
|
|
218
|
+
resolved = os.path.realpath(os.path.join(destination, member.name))
|
|
219
|
+
if not resolved.startswith(os.path.realpath(destination) + os.sep):
|
|
220
|
+
raise TrayInstallError(
|
|
221
|
+
f"{os.path.basename(archive)} tries to write outside its install directory "
|
|
222
|
+
f"({member.name}). Nothing was installed."
|
|
223
|
+
)
|
|
224
|
+
if member.issym() or member.islnk():
|
|
225
|
+
raise TrayInstallError(
|
|
226
|
+
f"{os.path.basename(archive)} contains a link ({member.name}). "
|
|
227
|
+
"Nothing was installed."
|
|
228
|
+
)
|
|
229
|
+
names.append(member.name)
|
|
230
|
+
tar.extractall(destination) # noqa: S202 (members checked above)
|
|
231
|
+
return names
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _executable_in(destination, names):
|
|
235
|
+
"""The companion inside what was unpacked, made executable.
|
|
236
|
+
|
|
237
|
+
A bundle wins over a bare binary, and deliberately not by relying on tar
|
|
238
|
+
member order. The macOS asset contains both `CDX.app` and the executable
|
|
239
|
+
inside it, and launching that inner binary directly would lose the bundle:
|
|
240
|
+
no `Info.plist`, so no `LSUIElement` and no stable identity for the
|
|
241
|
+
notification grant. Which of the two an ordered scan found first would then
|
|
242
|
+
depend on how the archive happened to be written.
|
|
243
|
+
|
|
244
|
+
An AppleDouble companion is not a candidate. macOS tar stores extended
|
|
245
|
+
attributes as a `._name` member beside the real one, and `._CDX.app` ends in
|
|
246
|
+
`.app` while being a few hundred bytes of metadata. BSD tar hides those
|
|
247
|
+
members on listing because it merges them back, so an archive can look clean
|
|
248
|
+
and still install one over the real bundle.
|
|
249
|
+
"""
|
|
250
|
+
def candidate(name):
|
|
251
|
+
base = os.path.basename(name.rstrip("/"))
|
|
252
|
+
return base if not base.startswith("._") else ""
|
|
253
|
+
|
|
254
|
+
bundles = [n for n in names if candidate(n).endswith(".app")]
|
|
255
|
+
binaries = [n for n in names if candidate(n) in ("cdx-tray", "cdx-tray.exe")]
|
|
256
|
+
for name in bundles + binaries:
|
|
257
|
+
path = os.path.join(destination, name.rstrip("/"))
|
|
258
|
+
if os.path.isdir(path):
|
|
259
|
+
return path
|
|
260
|
+
if os.path.isfile(path):
|
|
261
|
+
os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR)
|
|
262
|
+
return path
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def staged_dir(base_dir):
|
|
267
|
+
return os.path.join(state_dir(base_dir), "companion.staged")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def update(base_dir, version, download=None, ledger_path=CHECKSUM_LEDGER, target=None, probe=None):
|
|
271
|
+
"""Replace an installed companion without ever being left with none.
|
|
272
|
+
|
|
273
|
+
The order is the whole point. The replacement is downloaded, verified and
|
|
274
|
+
proved to start *before* the working one is touched, so a bad asset, a
|
|
275
|
+
truncated download or a binary that cannot execute on this machine costs an
|
|
276
|
+
error message rather than a tray the user can no longer start.
|
|
277
|
+
|
|
278
|
+
A staged directory left behind is a recoverable state, not corruption:
|
|
279
|
+
`cdx tray doctor` reports it and the next update replaces it.
|
|
280
|
+
"""
|
|
281
|
+
previous = read_state(base_dir)
|
|
282
|
+
if not previous:
|
|
283
|
+
raise TrayInstallError("No tray companion is installed, so there is nothing to update.")
|
|
284
|
+
|
|
285
|
+
staged = staged_dir(base_dir)
|
|
286
|
+
shutil.rmtree(staged, ignore_errors=True)
|
|
287
|
+
fresh = install(
|
|
288
|
+
base_dir, version,
|
|
289
|
+
download=download, ledger_path=ledger_path, target=target,
|
|
290
|
+
destination=staged, record=False,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Proving it starts is what separates staging from hoping. A binary for the
|
|
294
|
+
# wrong architecture, or one missing a library, fails here rather than after
|
|
295
|
+
# the working companion is gone.
|
|
296
|
+
runner = probe or _probe
|
|
297
|
+
if not runner(fresh["executable"]):
|
|
298
|
+
shutil.rmtree(staged, ignore_errors=True)
|
|
299
|
+
raise TrayInstallError(
|
|
300
|
+
f"The staged tray companion for CDX {version} did not start. "
|
|
301
|
+
"The installed one was left untouched."
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
live = os.path.join(state_dir(base_dir), "companion")
|
|
305
|
+
retired = os.path.join(state_dir(base_dir), "companion.previous")
|
|
306
|
+
shutil.rmtree(retired, ignore_errors=True)
|
|
307
|
+
if os.path.isdir(live):
|
|
308
|
+
os.rename(live, retired)
|
|
309
|
+
os.rename(staged, live)
|
|
310
|
+
# Only now is the previous one retired.
|
|
311
|
+
shutil.rmtree(retired, ignore_errors=True)
|
|
312
|
+
|
|
313
|
+
state = {
|
|
314
|
+
**fresh,
|
|
315
|
+
"executable": fresh["executable"].replace(staged, live, 1),
|
|
316
|
+
"paths": [live],
|
|
317
|
+
}
|
|
318
|
+
with open(state_path(base_dir), "w", encoding="utf-8") as handle:
|
|
319
|
+
json.dump(state, handle, indent=2)
|
|
320
|
+
return {"state": state, "replaced": previous.get("cdx_version")}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _probe(executable):
|
|
324
|
+
"""Does this companion start at all? `--print` exits non-zero when CDX is
|
|
325
|
+
unavailable, which is not the question here, so any completed run counts."""
|
|
326
|
+
try:
|
|
327
|
+
subprocess.run( # noqa: S603 (path comes from our own install state)
|
|
328
|
+
launch_command(executable) + ["--print"],
|
|
329
|
+
capture_output=True, timeout=20, check=False,
|
|
330
|
+
)
|
|
331
|
+
except (OSError, subprocess.SubprocessError):
|
|
332
|
+
return False
|
|
333
|
+
return True
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def interrupted_update(base_dir):
|
|
337
|
+
"""A staged directory nobody promoted: an update that died mid-flight."""
|
|
338
|
+
return os.path.isdir(staged_dir(base_dir))
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def uninstall(base_dir):
|
|
342
|
+
"""Remove exactly what install recorded, and the record itself."""
|
|
343
|
+
state = read_state(base_dir)
|
|
344
|
+
if not state:
|
|
345
|
+
return None
|
|
346
|
+
removed = []
|
|
347
|
+
for path in state.get("paths") or []:
|
|
348
|
+
if os.path.isdir(path):
|
|
349
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
350
|
+
removed.append(path)
|
|
351
|
+
elif os.path.exists(path):
|
|
352
|
+
os.remove(path)
|
|
353
|
+
removed.append(path)
|
|
354
|
+
try:
|
|
355
|
+
os.remove(state_path(base_dir))
|
|
356
|
+
except OSError:
|
|
357
|
+
pass
|
|
358
|
+
return {"removed": removed, "state": state}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def launch_command(executable):
|
|
362
|
+
"""How to start the companion without holding on to it.
|
|
363
|
+
|
|
364
|
+
On macOS an installed companion is an app bundle, and `open` hands it to
|
|
365
|
+
launchd so it survives this process exiting. A bare binary is spawned
|
|
366
|
+
directly, which is what a development build is.
|
|
367
|
+
"""
|
|
368
|
+
if platform.system() == "Darwin" and executable.endswith(".app"):
|
|
369
|
+
return ["open", "-a", executable]
|
|
370
|
+
return [executable]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Is a tray companion running, and which one.
|
|
2
|
+
|
|
3
|
+
The companion owns the lock; this only reads it. Both sides must agree on the
|
|
4
|
+
path, which is why the rule is written in one sentence in both places: the
|
|
5
|
+
per-user temporary directory, then `cdx-tray/companion.pid`.
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def lock_path(env=None):
|
|
14
|
+
env = os.environ if env is None else env
|
|
15
|
+
if platform.system() == "Windows":
|
|
16
|
+
base = env.get("LOCALAPPDATA") or tempfile.gettempdir()
|
|
17
|
+
else:
|
|
18
|
+
base = env.get("TMPDIR") or tempfile.gettempdir()
|
|
19
|
+
return os.path.join(base, "cdx-tray", "companion.pid")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _is_alive(pid):
|
|
23
|
+
if platform.system() == "Windows":
|
|
24
|
+
result = subprocess.run(
|
|
25
|
+
["tasklist", "/fi", f"PID eq {pid}", "/nh"],
|
|
26
|
+
capture_output=True, text=True, check=False,
|
|
27
|
+
)
|
|
28
|
+
return str(pid) in result.stdout
|
|
29
|
+
try:
|
|
30
|
+
os.kill(pid, 0)
|
|
31
|
+
except (ProcessLookupError, ValueError):
|
|
32
|
+
return False
|
|
33
|
+
except PermissionError:
|
|
34
|
+
# Alive, owned by someone else. Still a reason not to start a second.
|
|
35
|
+
return True
|
|
36
|
+
return True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def companion_instance(env=None):
|
|
40
|
+
"""`pid` when one is running, and whether a stale lock is lying around.
|
|
41
|
+
|
|
42
|
+
A stale lock is worth reporting rather than hiding: it is the visible trace
|
|
43
|
+
of a companion that crashed, and the next launch reclaims it.
|
|
44
|
+
"""
|
|
45
|
+
path = lock_path(env=env)
|
|
46
|
+
try:
|
|
47
|
+
with open(path, encoding="utf-8") as handle:
|
|
48
|
+
raw = handle.read().strip()
|
|
49
|
+
except OSError:
|
|
50
|
+
return {"pid": None, "stale": False, "lock": path}
|
|
51
|
+
try:
|
|
52
|
+
pid = int(raw)
|
|
53
|
+
except ValueError:
|
|
54
|
+
return {"pid": None, "stale": True, "lock": path}
|
|
55
|
+
if _is_alive(pid):
|
|
56
|
+
return {"pid": pid, "stale": False, "lock": path}
|
|
57
|
+
return {"pid": None, "stale": True, "lock": path}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""The Windows Start Menu shortcut a toast needs to exist at all.
|
|
2
|
+
|
|
3
|
+
Windows shows a toast from a program that is not packaged only when a Start Menu
|
|
4
|
+
shortcut carrying its AppUserModelID exists. Without one the notification API
|
|
5
|
+
returns success and nothing appears, which is why this is created at install
|
|
6
|
+
time rather than left to the user, and why `cdx tray doctor` reports it.
|
|
7
|
+
|
|
8
|
+
The AppUserModelID cannot be set through `WScript.Shell`: it lives in the
|
|
9
|
+
shortcut's property store, reachable only through `IShellLink` plus
|
|
10
|
+
`IPropertyStore`. So the work is done by a PowerShell script that declares just
|
|
11
|
+
those two interfaces. That is more machinery than a shortcut deserves, and it is
|
|
12
|
+
the smallest amount that produces a shortcut a toast will actually use.
|
|
13
|
+
|
|
14
|
+
Everything here is best-effort by design. A companion that installed correctly
|
|
15
|
+
must not be reported as failed because a shortcut could not be written; the
|
|
16
|
+
consequence is that notifications will not appear, and doctor says so.
|
|
17
|
+
"""
|
|
18
|
+
import os
|
|
19
|
+
import platform
|
|
20
|
+
import subprocess
|
|
21
|
+
import tempfile
|
|
22
|
+
|
|
23
|
+
SHORTCUT_NAME = "CDX.lnk"
|
|
24
|
+
# Matches the macOS bundle identifier and what the companion registers with.
|
|
25
|
+
# These two have to agree or Windows routes the toast to nothing.
|
|
26
|
+
APP_USER_MODEL_ID = "com.cdx.tray"
|
|
27
|
+
CREATE_TIMEOUT_SECONDS = 60
|
|
28
|
+
|
|
29
|
+
# Declared inline rather than shipped as a file: it has to survive a pip install
|
|
30
|
+
# of the package, and a one-purpose script beside the code that calls it is
|
|
31
|
+
# easier to keep honest than a data file that can go missing.
|
|
32
|
+
_PS_SCRIPT = r"""
|
|
33
|
+
param([string]$Lnk, [string]$Target, [string]$Aumid, [string]$Icon)
|
|
34
|
+
$ErrorActionPreference = "Stop"
|
|
35
|
+
Add-Type -TypeDefinition @"
|
|
36
|
+
using System;
|
|
37
|
+
using System.Runtime.InteropServices;
|
|
38
|
+
|
|
39
|
+
[StructLayout(LayoutKind.Sequential, Pack = 4)]
|
|
40
|
+
public struct PropertyKey { public Guid fmtid; public uint pid;
|
|
41
|
+
public PropertyKey(Guid g, uint p) { fmtid = g; pid = p; } }
|
|
42
|
+
|
|
43
|
+
// PROPVARIANT is 24 bytes on x64: vt plus three reserved words, then a union
|
|
44
|
+
// whose largest member is 16. Declaring it shorter lets GetValue write past the
|
|
45
|
+
// end of the managed struct, and the symptom is a value that silently reads
|
|
46
|
+
// back as VT_EMPTY rather than a crash.
|
|
47
|
+
[StructLayout(LayoutKind.Explicit, Size = 24)]
|
|
48
|
+
public struct PropVariant {
|
|
49
|
+
[FieldOffset(0)] public ushort vt;
|
|
50
|
+
[FieldOffset(8)] public IntPtr p;
|
|
51
|
+
[FieldOffset(16)] public IntPtr p2;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
[ComImport, Guid("886d8eeb-8cf2-4446-8d02-cdba1dbdcf99"),
|
|
55
|
+
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
56
|
+
public interface IPropertyStore {
|
|
57
|
+
int GetCount(out uint c);
|
|
58
|
+
int GetAt(uint i, out PropertyKey k);
|
|
59
|
+
int GetValue(ref PropertyKey k, out PropVariant v);
|
|
60
|
+
int SetValue(ref PropertyKey k, ref PropVariant v);
|
|
61
|
+
int Commit();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
[ComImport, Guid("0000010b-0000-0000-C000-000000000046"),
|
|
65
|
+
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
66
|
+
public interface IPersistFile {
|
|
67
|
+
int GetClassID(out Guid pClassID);
|
|
68
|
+
int IsDirty();
|
|
69
|
+
int Load([MarshalAs(UnmanagedType.LPWStr)] string f, uint mode);
|
|
70
|
+
int Save([MarshalAs(UnmanagedType.LPWStr)] string f, [MarshalAs(UnmanagedType.Bool)] bool remember);
|
|
71
|
+
int SaveCompleted([MarshalAs(UnmanagedType.LPWStr)] string f);
|
|
72
|
+
int GetCurFile([MarshalAs(UnmanagedType.LPWStr)] out string f);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
[ComImport, Guid("000214F9-0000-0000-C000-000000000046"),
|
|
76
|
+
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
77
|
+
public interface IShellLinkW {
|
|
78
|
+
int GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder f, int c, IntPtr fd, uint flags);
|
|
79
|
+
int GetIDList(out IntPtr ppidl);
|
|
80
|
+
int SetIDList(IntPtr pidl);
|
|
81
|
+
int GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder n, int c);
|
|
82
|
+
int SetDescription([MarshalAs(UnmanagedType.LPWStr)] string n);
|
|
83
|
+
int GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder d, int c);
|
|
84
|
+
int SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string d);
|
|
85
|
+
int GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder a, int c);
|
|
86
|
+
int SetArguments([MarshalAs(UnmanagedType.LPWStr)] string a);
|
|
87
|
+
int GetHotkey(out ushort h); int SetHotkey(ushort h);
|
|
88
|
+
int GetShowCmd(out int c); int SetShowCmd(int c);
|
|
89
|
+
int GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder i, int c, out int idx);
|
|
90
|
+
int SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string i, int idx);
|
|
91
|
+
int SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string r, uint res);
|
|
92
|
+
int Resolve(IntPtr hwnd, uint flags);
|
|
93
|
+
int SetPath([MarshalAs(UnmanagedType.LPWStr)] string f);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
[ComImport, Guid("00021401-0000-0000-C000-000000000046")]
|
|
97
|
+
public class ShellLink { }
|
|
98
|
+
|
|
99
|
+
public static class CdxShortcut {
|
|
100
|
+
static readonly Guid AppUserModel = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3");
|
|
101
|
+
const ushort VT_LPWSTR = 31;
|
|
102
|
+
[DllImport("ole32.dll")] static extern int PropVariantClear(ref PropVariant pv);
|
|
103
|
+
|
|
104
|
+
public static void Write(string lnk, string target, string aumid, string icon) {
|
|
105
|
+
var link = (IShellLinkW)new ShellLink();
|
|
106
|
+
link.SetPath(target);
|
|
107
|
+
link.SetDescription("CDX quota tray");
|
|
108
|
+
// Windows takes the toast, Start Menu and taskbar icon from the shortcut.
|
|
109
|
+
// Without this it shows a generic one, which is what a user reads as "some
|
|
110
|
+
// sort of computer screen" instead of CDX.
|
|
111
|
+
if (!String.IsNullOrEmpty(icon)) { link.SetIconLocation(icon, 0); }
|
|
112
|
+
var key = new PropertyKey(AppUserModel, 5);
|
|
113
|
+
var pv = new PropVariant();
|
|
114
|
+
pv.vt = VT_LPWSTR;
|
|
115
|
+
pv.p = Marshal.StringToCoTaskMemUni(aumid);
|
|
116
|
+
var store = (IPropertyStore)link;
|
|
117
|
+
Marshal.ThrowExceptionForHR(store.SetValue(ref key, ref pv));
|
|
118
|
+
// Commit before Save: the property store is written into the shortcut, and
|
|
119
|
+
// saving first would persist it without the identifier.
|
|
120
|
+
Marshal.ThrowExceptionForHR(store.Commit());
|
|
121
|
+
PropVariantClear(ref pv);
|
|
122
|
+
Marshal.ThrowExceptionForHR(((IPersistFile)link).Save(lnk, true));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
public static string Read(string lnk) {
|
|
126
|
+
var link = (IShellLinkW)new ShellLink();
|
|
127
|
+
Marshal.ThrowExceptionForHR(((IPersistFile)link).Load(lnk, 0));
|
|
128
|
+
var key = new PropertyKey(AppUserModel, 5);
|
|
129
|
+
PropVariant pv;
|
|
130
|
+
Marshal.ThrowExceptionForHR(((IPropertyStore)link).GetValue(ref key, out pv));
|
|
131
|
+
string value = pv.vt == VT_LPWSTR ? Marshal.PtrToStringUni(pv.p) : "";
|
|
132
|
+
PropVariantClear(ref pv);
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
"@
|
|
137
|
+
[CdxShortcut]::Write($Lnk, $Target, $Aumid, $Icon)
|
|
138
|
+
Write-Output ([CdxShortcut]::Read($Lnk))
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def shortcut_path(env=None):
|
|
143
|
+
"""Where Windows looks, or None when the Start Menu cannot be resolved."""
|
|
144
|
+
env = os.environ if env is None else env
|
|
145
|
+
appdata = (env.get("APPDATA") or "").strip()
|
|
146
|
+
if not appdata:
|
|
147
|
+
return None
|
|
148
|
+
return os.path.join(appdata, "Microsoft", "Windows", "Start Menu", "Programs", SHORTCUT_NAME)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def icon_beside(executable):
|
|
152
|
+
"""The `.ico` shipped next to the companion, or None.
|
|
153
|
+
|
|
154
|
+
Resolved from the installed layout rather than from the repository: the
|
|
155
|
+
shortcut records an absolute path that Windows reads later, so pointing it
|
|
156
|
+
at a file that only exists on the build machine would leave every user with
|
|
157
|
+
the generic icon and no way to tell why.
|
|
158
|
+
"""
|
|
159
|
+
for candidate in (
|
|
160
|
+
os.path.join(os.path.dirname(executable), "CDX.ico"),
|
|
161
|
+
os.path.join(os.path.dirname(os.path.dirname(executable)), "CDX.ico"),
|
|
162
|
+
):
|
|
163
|
+
if os.path.isfile(candidate):
|
|
164
|
+
return candidate
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def create(executable, env=None, system=None, run=None):
|
|
169
|
+
"""Write the shortcut, and report what happened without ever raising.
|
|
170
|
+
|
|
171
|
+
Returns {"created": bool, "path": str|None, "reason": str|None}. A failure
|
|
172
|
+
here must not fail an install that otherwise worked: the companion runs
|
|
173
|
+
either way, only its notifications would not appear, and doctor reports that
|
|
174
|
+
rather than leaving the user to discover silence.
|
|
175
|
+
"""
|
|
176
|
+
system = platform.system() if system is None else system
|
|
177
|
+
if system != "Windows":
|
|
178
|
+
return {"created": False, "path": None, "reason": "not required on this platform"}
|
|
179
|
+
|
|
180
|
+
path = shortcut_path(env)
|
|
181
|
+
if not path:
|
|
182
|
+
return {"created": False, "path": None, "reason": "APPDATA is unset"}
|
|
183
|
+
|
|
184
|
+
runner = run or _run_powershell
|
|
185
|
+
try:
|
|
186
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
187
|
+
readback = runner(path, executable, APP_USER_MODEL_ID, icon_beside(executable) or "")
|
|
188
|
+
except Exception as error: # noqa: BLE001 - never fail an install over this
|
|
189
|
+
return {"created": False, "path": path, "reason": str(error)}
|
|
190
|
+
|
|
191
|
+
# Trust the shortcut only if it names the identifier back. A file that
|
|
192
|
+
# exists without one is a shortcut Windows will not route a toast through,
|
|
193
|
+
# and reporting it as created would be the silent failure this exists to end.
|
|
194
|
+
if (readback or "").strip() != APP_USER_MODEL_ID:
|
|
195
|
+
return {
|
|
196
|
+
"created": False,
|
|
197
|
+
"path": path,
|
|
198
|
+
"reason": f"the shortcut was written without its AppUserModelID (read back {readback!r})",
|
|
199
|
+
}
|
|
200
|
+
return {"created": True, "path": path, "reason": None}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _run_powershell(path, executable, aumid, icon=""):
|
|
204
|
+
"""Run the script from a temporary file, because `param()` needs `-File`.
|
|
205
|
+
|
|
206
|
+
Passing the same text to `-Command` runs it, but the arguments after it are
|
|
207
|
+
never bound to the parameters, so the shortcut would be written to an empty
|
|
208
|
+
path — a failure that looks like success from the outside.
|
|
209
|
+
"""
|
|
210
|
+
with tempfile.TemporaryDirectory(prefix="cdx-tray-lnk-") as scratch:
|
|
211
|
+
script = os.path.join(scratch, "shortcut.ps1")
|
|
212
|
+
with open(script, "w", encoding="utf-8") as handle:
|
|
213
|
+
handle.write(_PS_SCRIPT)
|
|
214
|
+
completed = subprocess.run(
|
|
215
|
+
[
|
|
216
|
+
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script,
|
|
217
|
+
"-Lnk", path, "-Target", executable, "-Aumid", aumid, "-Icon", icon,
|
|
218
|
+
],
|
|
219
|
+
capture_output=True, text=True, timeout=CREATE_TIMEOUT_SECONDS, check=False,
|
|
220
|
+
)
|
|
221
|
+
if completed.returncode != 0:
|
|
222
|
+
raise OSError((completed.stderr or "powershell failed").strip()[:200])
|
|
223
|
+
return completed.stdout.strip().splitlines()[-1] if completed.stdout.strip() else ""
|