cdx-manager 0.17.0 → 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.
@@ -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 ""