cdx-manager 0.18.4 → 0.18.5

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,80 @@
1
+ """Which terminal a tray session row opens.
2
+
3
+ The companion picks one per platform today: Terminal.app on macOS, the first of
4
+ four names that answers on Linux. Someone who works in iTerm or Ghostty gets a
5
+ second terminal application opened beside the one they were already in, which is
6
+ the opposite of the shortcut the row is meant to be.
7
+
8
+ What is stored is an application, never a command, and that restriction is the
9
+ whole security argument. A preference that could hold a command line would be a
10
+ way to run anything, in a process the user did not start, triggered by clicking
11
+ a menu row — and it would be writable by anything that can write CDX's state.
12
+ The validation below is what makes "open my terminal" impossible to turn into
13
+ "run this". Nothing here reaches a shell: the value names an application and the
14
+ platform's own launcher resolves it.
15
+ """
16
+ import json
17
+ import os
18
+ import re
19
+
20
+ STATE_VERSION = 1
21
+ # An application name or a bundle identifier, and nothing that could be read as
22
+ # a command. No slashes, so it cannot name a path; no quotes, semicolons,
23
+ # ampersands, pipes, backticks or dollars, so it cannot carry a shell fragment;
24
+ # it must start with a letter or a digit, so it cannot be read as a flag.
25
+ _NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$")
26
+
27
+ REFUSAL = (
28
+ "A terminal preference is an application name, not a command: "
29
+ "letters, digits, spaces, dots, dashes and underscores only. "
30
+ 'Examples: "iTerm", "Ghostty", "WezTerm", "kitty", "wt".'
31
+ )
32
+
33
+
34
+ def state_path(base_dir):
35
+ return os.path.join(base_dir, "tray", "terminal.json")
36
+
37
+
38
+ def terminal_preference(base_dir):
39
+ """The chosen terminal, or None for the platform's default.
40
+
41
+ Anything unreadable, unrecognised, or no longer valid reads as no
42
+ preference. A value that fails validation today must not be honoured just
43
+ because it was written before the rule existed.
44
+ """
45
+ try:
46
+ with open(state_path(base_dir), encoding="utf-8") as handle:
47
+ state = json.load(handle)
48
+ except (OSError, ValueError):
49
+ return None
50
+ if not isinstance(state, dict) or state.get("version") != STATE_VERSION:
51
+ return None
52
+ name = state.get("terminal")
53
+ return name if isinstance(name, str) and _NAME.match(name) else None
54
+
55
+
56
+ def valid_terminal(name):
57
+ return isinstance(name, str) and bool(_NAME.match(name))
58
+
59
+
60
+ def set_terminal(base_dir, name):
61
+ """Record a preferred terminal. Raises ValueError on anything else."""
62
+ if not valid_terminal(name):
63
+ raise ValueError(REFUSAL)
64
+ _write(base_dir, {"version": STATE_VERSION, "terminal": name})
65
+ return name
66
+
67
+
68
+ def clear_terminal(base_dir):
69
+ """Return to the platform's default."""
70
+ _write(base_dir, {"version": STATE_VERSION, "terminal": None})
71
+ return None
72
+
73
+
74
+ def _write(base_dir, state):
75
+ path = state_path(base_dir)
76
+ os.makedirs(os.path.dirname(path), exist_ok=True)
77
+ temp = f"{path}.{os.getpid()}.tmp"
78
+ with open(temp, "w", encoding="utf-8") as handle:
79
+ json.dump(state, handle, indent=2)
80
+ os.replace(temp, path)