chartremotely 0.2.4__tar.gz → 0.2.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {chartremotely-0.2.4 → chartremotely-0.2.6}/PKG-INFO +15 -10
  2. {chartremotely-0.2.4 → chartremotely-0.2.6}/README.md +14 -9
  3. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/assets/shortcut-template.plist +12 -0
  4. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/config.py +0 -2
  5. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/forward.py +13 -24
  6. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/relay.py +2 -1
  7. chartremotely-0.2.6/chartremotely/requestlog.py +41 -0
  8. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/server.py +8 -3
  9. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/services.py +23 -4
  10. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/setup.py +0 -2
  11. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely.egg-info/PKG-INFO +15 -10
  12. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely.egg-info/SOURCES.txt +3 -0
  13. {chartremotely-0.2.4 → chartremotely-0.2.6}/pyproject.toml +1 -1
  14. {chartremotely-0.2.4 → chartremotely-0.2.6}/tests/test_forward.py +60 -13
  15. chartremotely-0.2.6/tests/test_requestlog.py +23 -0
  16. {chartremotely-0.2.4 → chartremotely-0.2.6}/tests/test_setup.py +58 -1
  17. chartremotely-0.2.6/tests/test_stamp_changelog.py +75 -0
  18. {chartremotely-0.2.4 → chartremotely-0.2.6}/LICENSE +0 -0
  19. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/__init__.py +0 -0
  20. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/answer.py +0 -0
  21. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/ax.py +0 -0
  22. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/cli.py +0 -0
  23. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/doctor.py +0 -0
  24. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/guilock.py +0 -0
  25. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/keystore.py +0 -0
  26. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/layout.py +0 -0
  27. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/mcpclient.py +0 -0
  28. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/push.py +0 -0
  29. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/registry.py +0 -0
  30. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/resolve.py +0 -0
  31. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/scales.py +0 -0
  32. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/shortcut.py +0 -0
  33. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/snapshot.py +0 -0
  34. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/studies.py +0 -0
  35. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/symbol.py +0 -0
  36. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/tailnet.py +0 -0
  37. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/timeframe.py +0 -0
  38. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/vocab.py +0 -0
  39. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely/window.py +0 -0
  40. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely.egg-info/dependency_links.txt +0 -0
  41. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely.egg-info/entry_points.txt +0 -0
  42. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely.egg-info/requires.txt +0 -0
  43. {chartremotely-0.2.4 → chartremotely-0.2.6}/chartremotely.egg-info/top_level.txt +0 -0
  44. {chartremotely-0.2.4 → chartremotely-0.2.6}/setup.cfg +0 -0
  45. {chartremotely-0.2.4 → chartremotely-0.2.6}/tests/test_push.py +0 -0
  46. {chartremotely-0.2.4 → chartremotely-0.2.6}/tests/test_relay.py +0 -0
  47. {chartremotely-0.2.4 → chartremotely-0.2.6}/tests/test_resolve.py +0 -0
  48. {chartremotely-0.2.4 → chartremotely-0.2.6}/tests/test_snapshot.py +0 -0
  49. {chartremotely-0.2.4 → chartremotely-0.2.6}/tests/test_timeframe.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -319,15 +319,20 @@ ignores everything sent to it.
319
319
 
320
320
  1. **Which company?** — a spoken name or a spelled ticker.
321
321
  2. **What scale?** — a time frame mnemonic (below), or "as is".
322
- 3. **Where?** — which display, by the name it was paired under. Say this
323
- Mac's own name to stay here.
324
-
325
- The Shortcut talks to one Mac — the one that made it. When "Where?" names
326
- another of your displays, that Mac hands the command to the operator, which
327
- finds the display among yours by name only (case, spaces, hyphens,
328
- underscores and dots do not count: "Mac mini" is "mac-mini") and relays it.
329
- The reply you hear is the other display's. An unknown name is answered with
330
- the names you do have.
322
+ 3. **Where?** — which display. Leave it blank to stay on this Mac.
323
+
324
+ The Shortcut talks to one Mac — the one that made it. Any name said at
325
+ "Where?" goes to the operator, which finds the display among yours and
326
+ relays the command there (or back here, if the name is this Mac's). Names
327
+ may be loose: for a display paired as "Mac mini", "mac-mini", "mini mac",
328
+ "mini", "macm" and even "mack meeny" all work. The reply you hear is that
329
+ display's. A name that could mean several displays is answered with them,
330
+ and an unknown name with the names you do have.
331
+
332
+ Each command handled is logged as one line — time, the command's first word,
333
+ the "Where?" as said, and the reply if it was an ERR — to
334
+ `~/Library/Logs/chartremotely-serve.log` (voice) or `chartremotely-relay.log`
335
+ (from the operator). Never a token, a secret or a picture.
331
336
 
332
337
  ## Running it as a service
333
338
 
@@ -95,15 +95,20 @@ ignores everything sent to it.
95
95
 
96
96
  1. **Which company?** — a spoken name or a spelled ticker.
97
97
  2. **What scale?** — a time frame mnemonic (below), or "as is".
98
- 3. **Where?** — which display, by the name it was paired under. Say this
99
- Mac's own name to stay here.
100
-
101
- The Shortcut talks to one Mac — the one that made it. When "Where?" names
102
- another of your displays, that Mac hands the command to the operator, which
103
- finds the display among yours by name only (case, spaces, hyphens,
104
- underscores and dots do not count: "Mac mini" is "mac-mini") and relays it.
105
- The reply you hear is the other display's. An unknown name is answered with
106
- the names you do have.
98
+ 3. **Where?** — which display. Leave it blank to stay on this Mac.
99
+
100
+ The Shortcut talks to one Mac — the one that made it. Any name said at
101
+ "Where?" goes to the operator, which finds the display among yours and
102
+ relays the command there (or back here, if the name is this Mac's). Names
103
+ may be loose: for a display paired as "Mac mini", "mac-mini", "mini mac",
104
+ "mini", "macm" and even "mack meeny" all work. The reply you hear is that
105
+ display's. A name that could mean several displays is answered with them,
106
+ and an unknown name with the names you do have.
107
+
108
+ Each command handled is logged as one line — time, the command's first word,
109
+ the "Where?" as said, and the reply if it was an ERR — to
110
+ `~/Library/Logs/chartremotely-serve.log` (voice) or `chartremotely-relay.log`
111
+ (from the operator). Never a token, a secret or a picture.
107
112
 
108
113
  ## Running it as a service
109
114
 
@@ -17,6 +17,8 @@
17
17
  <string>Which company?</string>
18
18
  <key>WFInputType</key>
19
19
  <string>Text</string>
20
+ <key>WFAskActionAllowsMultilineText</key>
21
+ <false/>
20
22
  </dict>
21
23
  </dict>
22
24
  <dict>
@@ -231,6 +233,8 @@
231
233
  <string>Which company?</string>
232
234
  <key>WFInputType</key>
233
235
  <string>Text</string>
236
+ <key>WFAskActionAllowsMultilineText</key>
237
+ <false/>
234
238
  </dict>
235
239
  </dict>
236
240
  <dict>
@@ -425,6 +429,8 @@
425
429
  <string>Spell the symbol</string>
426
430
  <key>WFInputType</key>
427
431
  <string>Text</string>
432
+ <key>WFAskActionAllowsMultilineText</key>
433
+ <false/>
428
434
  </dict>
429
435
  </dict>
430
436
  <dict>
@@ -753,6 +759,8 @@
753
759
  <string>What scale?</string>
754
760
  <key>WFInputType</key>
755
761
  <string>Text</string>
762
+ <key>WFAskActionAllowsMultilineText</key>
763
+ <false/>
756
764
  </dict>
757
765
  </dict>
758
766
  <dict>
@@ -967,6 +975,8 @@
967
975
  <string>Which timeframe?</string>
968
976
  <key>WFInputType</key>
969
977
  <string>Text</string>
978
+ <key>WFAskActionAllowsMultilineText</key>
979
+ <false/>
970
980
  </dict>
971
981
  </dict>
972
982
  <dict>
@@ -1052,6 +1062,8 @@
1052
1062
  <string>Where?</string>
1053
1063
  <key>WFInputType</key>
1054
1064
  <string>Text</string>
1065
+ <key>WFAskActionAllowsMultilineText</key>
1066
+ <false/>
1055
1067
  </dict>
1056
1068
  </dict>
1057
1069
  <dict>
@@ -34,8 +34,6 @@ DEFAULTS: dict = {
34
34
  "operator_url": None,
35
35
  "agent_id": None,
36
36
  "agent_secret": None,
37
- # This display's name at the operator, for "Where?" in the Shortcut.
38
- "display_label": None,
39
37
  "token": None,
40
38
  "port": 8899,
41
39
  }
@@ -1,11 +1,11 @@
1
1
  """Send a spoken command on to another of the owner's displays.
2
2
 
3
3
  The Shortcut asks "Where?" and passes the dictated answer through verbatim.
4
- Empty, or this Mac's own name, means this Mac. Any other name goes to the
5
- operator's ``/agent/forward``, which looks it up among the SAME owner's
6
- displays by name only and relays the command there; the target's reply is
7
- what gets spoken. No inference here and none at the operator: the name is
8
- compared ignoring case, spaces, hyphens, underscores and dots, and that is all.
4
+ Empty, or this Mac's exact agent_id, means this Mac. Any other name goes to
5
+ the operator's ``/agent/forward``, which is the one place names are matched:
6
+ it finds the display among the SAME owner's displays (loosely - "mini mac"
7
+ finds "Mac mini") and relays the command there, or answers that the name is
8
+ this Mac's own. The target's reply is what gets spoken.
9
9
 
10
10
  Stdlib only, like the relay, so it imports where the macOS drivers do not.
11
11
  """
@@ -13,7 +13,6 @@ Stdlib only, like the relay, so it imports where the macOS drivers do not.
13
13
  from __future__ import annotations
14
14
 
15
15
  import json
16
- import re
17
16
  import urllib.error
18
17
  import urllib.request
19
18
 
@@ -23,21 +22,10 @@ ERR = "ERR "
23
22
  #: Longer than the operator's own wait on the target, so its 504 arrives first.
24
23
  TIMEOUT = 35.0
25
24
 
26
- _NAME_NOISE = re.compile(r"[\s\-_.]+")
27
-
28
-
29
- def display_key(name: str) -> str:
30
- """A display name as it is matched. The operator's ``display_key``, exactly."""
31
- return _NAME_NOISE.sub("", name or "").lower()
32
-
33
-
34
25
  def is_here(where: str, cfg: dict) -> bool:
35
- """Whether ``where`` means this Mac: empty, its display name, or its agent_id."""
26
+ """Whether ``where`` means this Mac without asking: empty, or exactly its agent_id."""
36
27
  wanted = (where or "").strip()
37
- if not wanted:
38
- return True
39
- label = cfg.get("display_label") or ""
40
- return wanted == cfg.get("agent_id") or bool(label) and display_key(wanted) == display_key(label)
28
+ return not wanted or wanted == cfg.get("agent_id")
41
29
 
42
30
 
43
31
  def _post(url: str, payload: dict) -> tuple[int, dict]:
@@ -69,17 +57,18 @@ def route(command: str, where: str, cfg: dict | None = None) -> str | None:
69
57
  agent_id, secret = cfg.get("agent_id"), cfg.get("agent_secret")
70
58
  if not (base and agent_id and secret):
71
59
  return ERR + "This Mac is not paired, so it can only drive its own chart."
72
- wanted = where.strip()
60
+ # The Shortcut builds the command from this Mac's own replies, and every
61
+ # reply ends in a newline ("PLTR\n"), so a forwarded command arrives as
62
+ # "set PLTR\n | half\n". Run here, vocab strips that; the operator rightly
63
+ # refuses anything unprintable. Collapse the whitespace before it leaves.
64
+ wanted = " ".join(where.split())
73
65
  try:
74
66
  status, body = _post(f"{base}/agent/forward",
75
67
  {"agent_id": agent_id, "secret": secret,
76
- "display": wanted, "cmd": command})
68
+ "display": wanted, "cmd": " ".join(command.split())})
77
69
  except (urllib.error.URLError, TimeoutError, OSError):
78
70
  return ERR + "I could not reach the operator."
79
71
  if status == 200 and body.get("self"):
80
- # Told its own name: remember it, so next time needs no round trip.
81
- if body.get("display"):
82
- config.update(display_label=str(body["display"]))
83
72
  return None
84
73
  if status == 200:
85
74
  return str(body.get("reply") or "")
@@ -18,7 +18,7 @@ import time
18
18
  import urllib.error
19
19
  import urllib.request
20
20
 
21
- from . import config
21
+ from . import config, requestlog
22
22
  from .answer import Answer
23
23
 
24
24
  #: Poll window. The operator holds a request open for slightly less than
@@ -144,6 +144,7 @@ def run(operator_url: str | None = None, once: bool = False) -> None:
144
144
  timeout=20)
145
145
  except (urllib.error.URLError, TimeoutError, OSError):
146
146
  pass # the caller has already timed out and refunded
147
+ requestlog.request("relay", command, "", reply)
147
148
  # After the reply, never before: the picture of a changed chart.
148
149
  from . import push
149
150
  push.after_reply(command, reply, result.symbol)
@@ -0,0 +1,41 @@
1
+ """One line to stderr per command handled, so a spoken ERR can be diagnosed.
2
+
3
+ launchd sends stderr to ``~/Library/Logs/chartremotely-<service>.log``. The
4
+ line carries only what is needed to tell requests apart: the time, the verb
5
+ (the command's first word, never its arguments), the "Where?" as dictated,
6
+ and the reply when it is an ERR. Never a token, a secret or a picture.
7
+
8
+ Stdlib only, like the relay, so it imports where the macOS drivers do not.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ import time
15
+
16
+ WHERE_MAX = 64
17
+ ERR_MAX = 200
18
+
19
+
20
+ def _clean(text: str, limit: int) -> str:
21
+ """``text`` on one line, printable only, at most ``limit`` characters."""
22
+ flat = "".join(c if c.isprintable() else " " for c in text)
23
+ return flat[:limit]
24
+
25
+
26
+ def line(source: str, command: str, where: str, reply: str) -> str:
27
+ """The log line for one request (see the module doc)."""
28
+ verb = _clean((command or "").split(maxsplit=1)[0] if (command or "").strip() else "-", 32)
29
+ parts = [time.strftime("%Y-%m-%dT%H:%M:%S%z"), source, f"verb={verb}",
30
+ f"where={_clean(where or '', WHERE_MAX)!r}"]
31
+ if (reply or "").startswith("ERR"):
32
+ parts.append(f"reply={_clean(reply, ERR_MAX)!r}")
33
+ return " ".join(parts)
34
+
35
+
36
+ def request(source: str, command: str, where: str, reply: str) -> None:
37
+ """Write :func:`line` to stderr. Never raises: logging must not cost a reply."""
38
+ try:
39
+ print(line(source, command, where, reply), file=sys.stderr, flush=True)
40
+ except (OSError, ValueError):
41
+ pass
@@ -17,7 +17,7 @@ import secrets
17
17
  import urllib.parse
18
18
  from http.server import BaseHTTPRequestHandler, HTTPServer
19
19
 
20
- from . import config, forward, push
20
+ from . import config, forward, push, requestlog
21
21
  from .vocab import answer
22
22
 
23
23
 
@@ -72,13 +72,18 @@ class Handler(BaseHTTPRequestHandler):
72
72
  """
73
73
  elsewhere = forward.route(request, where) if where and request else None
74
74
  if elsewhere is not None:
75
- return self._reply(200, elsewhere)
75
+ self._reply(200, elsewhere)
76
+ return requestlog.request("serve", request, where, elsewhere)
76
77
  result = answer(request)
77
78
  self._reply(200, result.reply)
79
+ requestlog.request("serve", request, where, result.reply)
78
80
  push.after_reply(request, result.reply, result.symbol)
79
81
 
80
82
  def log_message(self, *args) -> None:
81
- """Silence. Requests carry spoken input; do not write it to a log."""
83
+ """Silence the stock access log: its request line can carry ``?t=<token>``.
84
+
85
+ Each handled command is logged by :mod:`requestlog` instead, verb only.
86
+ """
82
87
 
83
88
 
84
89
  def serve(port: int | None = None) -> None:
@@ -56,6 +56,10 @@ def render(label: str, args: list[str], *, keep_alive: bool, log: Path) -> str:
56
56
  """
57
57
 
58
58
 
59
+ class ServiceError(RuntimeError):
60
+ """launchd would not start a service; the reason is launchctl's own."""
61
+
62
+
59
63
  def _domain() -> str:
60
64
  return f"gui/{os.getuid()}"
61
65
 
@@ -65,17 +69,32 @@ def loaded(label: str) -> bool:
65
69
  capture_output=True, check=False).returncode == 0
66
70
 
67
71
 
68
- def install(command: str) -> Path:
69
- """Write and (re)start one service. Idempotent."""
72
+ def install(command: str, settle: float = 10.0) -> Path:
73
+ """Write and (re)start one service. Idempotent.
74
+
75
+ ``bootout`` returns before launchd has let go of the label, and a
76
+ ``bootstrap`` in that window fails with "5: Input/output error" - which
77
+ left a re-run of setup with the listener stopped. So wait for the label to
78
+ be gone, and give ``bootstrap`` a few more tries while launchd settles.
79
+ """
70
80
  label = LABELS[command]
71
81
  path = AGENTS_DIR / f"{label}.plist"
72
82
  AGENTS_DIR.mkdir(parents=True, exist_ok=True)
73
83
  path.write_text(render(label, [executable(), command], keep_alive=True,
74
84
  log=LOG_DIR / f"chartremotely-{command}.log"))
85
+ deadline = time.monotonic() + settle
75
86
  if loaded(label):
76
87
  subprocess.run(["launchctl", "bootout", f"{_domain()}/{label}"], capture_output=True, check=False)
77
- subprocess.run(["launchctl", "bootstrap", _domain(), str(path)], check=True)
78
- return path
88
+ while loaded(label) and time.monotonic() < deadline:
89
+ time.sleep(0.25)
90
+ while True:
91
+ ran = subprocess.run(["launchctl", "bootstrap", _domain(), str(path)],
92
+ capture_output=True, text=True, check=False)
93
+ if ran.returncode == 0:
94
+ return path
95
+ if time.monotonic() >= deadline:
96
+ raise ServiceError(f"launchd would not start {label}: {(ran.stderr or ran.stdout).strip()[:200]}")
97
+ time.sleep(0.5)
79
98
 
80
99
 
81
100
  def probe_permissions(request: bool, out: Path, timeout: float = 30.0) -> dict:
@@ -157,8 +157,6 @@ def run(ask: Ask = input, say: Say = print, operator_url: str = OPERATOR_URL) ->
157
157
  label = ask("Name this display (e.g. Desk, Office wall): ").strip() or "display"
158
158
  pair(client, base, npub, proof, label)
159
159
  del proof
160
- # Kept so "Where? <this Mac's name>" runs here without asking the operator.
161
- config.update(display_label=label)
162
160
  say(f"Paired as “{label}”.")
163
161
 
164
162
  # 3. Tailscale.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -319,15 +319,20 @@ ignores everything sent to it.
319
319
 
320
320
  1. **Which company?** — a spoken name or a spelled ticker.
321
321
  2. **What scale?** — a time frame mnemonic (below), or "as is".
322
- 3. **Where?** — which display, by the name it was paired under. Say this
323
- Mac's own name to stay here.
324
-
325
- The Shortcut talks to one Mac — the one that made it. When "Where?" names
326
- another of your displays, that Mac hands the command to the operator, which
327
- finds the display among yours by name only (case, spaces, hyphens,
328
- underscores and dots do not count: "Mac mini" is "mac-mini") and relays it.
329
- The reply you hear is the other display's. An unknown name is answered with
330
- the names you do have.
322
+ 3. **Where?** — which display. Leave it blank to stay on this Mac.
323
+
324
+ The Shortcut talks to one Mac — the one that made it. Any name said at
325
+ "Where?" goes to the operator, which finds the display among yours and
326
+ relays the command there (or back here, if the name is this Mac's). Names
327
+ may be loose: for a display paired as "Mac mini", "mac-mini", "mini mac",
328
+ "mini", "macm" and even "mack meeny" all work. The reply you hear is that
329
+ display's. A name that could mean several displays is answered with them,
330
+ and an unknown name with the names you do have.
331
+
332
+ Each command handled is logged as one line — time, the command's first word,
333
+ the "Where?" as said, and the reply if it was an ERR — to
334
+ `~/Library/Logs/chartremotely-serve.log` (voice) or `chartremotely-relay.log`
335
+ (from the operator). Never a token, a secret or a picture.
331
336
 
332
337
  ## Running it as a service
333
338
 
@@ -15,6 +15,7 @@ chartremotely/mcpclient.py
15
15
  chartremotely/push.py
16
16
  chartremotely/registry.py
17
17
  chartremotely/relay.py
18
+ chartremotely/requestlog.py
18
19
  chartremotely/resolve.py
19
20
  chartremotely/scales.py
20
21
  chartremotely/server.py
@@ -38,7 +39,9 @@ chartremotely/assets/shortcut-template.plist
38
39
  tests/test_forward.py
39
40
  tests/test_push.py
40
41
  tests/test_relay.py
42
+ tests/test_requestlog.py
41
43
  tests/test_resolve.py
42
44
  tests/test_setup.py
43
45
  tests/test_snapshot.py
46
+ tests/test_stamp_changelog.py
44
47
  tests/test_timeframe.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "chartremotely"
3
- version = "0.2.4"
3
+ version = "0.2.6"
4
4
  description = "Remote control of desktop charting applications on macOS"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -50,7 +50,7 @@ def operator(monkeypatch, tmp_path):
50
50
  server = HTTPServer(("127.0.0.1", 0), StubOperator)
51
51
  threading.Thread(target=server.serve_forever, daemon=True).start()
52
52
  base = f"http://127.0.0.1:{server.server_address[1]}"
53
- config.update(operator_url=base, agent_id="a1", agent_secret="s1", display_label="Desk")
53
+ config.update(operator_url=base, agent_id="a1", agent_secret="s1")
54
54
  yield base
55
55
  server.shutdown()
56
56
 
@@ -67,15 +67,29 @@ def listener(operator, monkeypatch):
67
67
  from chartremotely import push
68
68
  monkeypatch.setattr(push, "after_reply", lambda *a: pictures.append(a))
69
69
  server.Handler.token = "T0KEN"
70
- http = HTTPServer(("127.0.0.1", 0), server.Handler)
70
+ handled = threading.Event()
71
+
72
+ class Finishing(server.Handler):
73
+ # The reply goes out before the picture is scheduled, so a test that
74
+ # checks pictures must wait for the whole request, not just its reply.
75
+ def handle(self):
76
+ try:
77
+ super().handle()
78
+ finally:
79
+ handled.set()
80
+
81
+ http = HTTPServer(("127.0.0.1", 0), Finishing)
71
82
  threading.Thread(target=http.serve_forever, daemon=True).start()
72
83
 
73
84
  def post(body):
85
+ handled.clear()
74
86
  request = urllib.request.Request(
75
87
  f"http://127.0.0.1:{http.server_address[1]}/chart", data=json.dumps(body).encode(),
76
88
  method="POST", headers={"Content-Type": "application/json", "X-Token": "T0KEN"})
77
89
  with urllib.request.urlopen(request, timeout=10) as response:
78
- return response.read().decode().strip()
90
+ reply = response.read().decode().strip()
91
+ assert handled.wait(5), "the listener never finished the request"
92
+ return reply
79
93
 
80
94
  yield post, ran, pictures
81
95
  http.shutdown()
@@ -90,13 +104,43 @@ def test_no_where_runs_here_exactly_as_before(listener):
90
104
  assert StubOperator.seen == []
91
105
 
92
106
 
93
- @pytest.mark.parametrize("said", ["Desk", "desk", " DESK ", "a1"])
94
- def test_this_macs_own_name_runs_here(listener, said):
107
+ @pytest.mark.parametrize("said", ["", " ", "a1", " a1 "])
108
+ def test_blank_or_this_macs_agent_id_runs_here_without_asking(listener, said):
95
109
  post, ran, _ = listener
96
110
  post({"cmd": "set PLTR | daily", "where": said})
97
111
  assert ran == ["set PLTR | daily"] and StubOperator.seen == []
98
112
 
99
113
 
114
+ @pytest.mark.parametrize("said", ["Desk", "desk", "mini mac", "A1"])
115
+ def test_every_name_is_matched_by_the_operator_not_here(listener, said):
116
+ post, ran, _ = listener
117
+ StubOperator.answer = (200, {"self": True, "display": "Desk"})
118
+ post({"cmd": "set PLTR | daily", "where": said})
119
+ assert [body["display"] for _, body in StubOperator.seen] == [said.strip()]
120
+ assert ran == ["set PLTR | daily"], "the operator said it is this Mac, so it runs here"
121
+
122
+
123
+ def test_is_here_is_blank_or_the_exact_agent_id():
124
+ cfg = {"agent_id": "a1"}
125
+ assert forward.is_here("", cfg) and forward.is_here(" a1 ", cfg)
126
+ assert not forward.is_here("A1", cfg) and not forward.is_here("Desk", cfg)
127
+ assert forward.is_here("", {}) and not forward.is_here("a1", {})
128
+
129
+
130
+ def test_each_request_is_logged_once_without_the_token_or_arguments(listener, capsys):
131
+ post, _, _ = listener
132
+ capsys.readouterr()
133
+ post({"cmd": "set PLTR | daily", "where": "desk"})
134
+ StubOperator.answer = (404, {"error": "no display named 'kitchen'", "displays": ["Desk"]})
135
+ post({"cmd": "read", "where": "kitchen"})
136
+ lines = capsys.readouterr().err.strip().splitlines()
137
+ assert len(lines) == 2
138
+ assert "serve verb=set where='desk'" in lines[0] and "reply=" not in lines[0]
139
+ assert "verb=read where='kitchen' reply='ERR No display named \"kitchen\". Yours: Desk.'" in lines[1]
140
+ for line in lines:
141
+ assert "T0KEN" not in line and "s1" not in line and "PLTR" not in line
142
+
143
+
100
144
  def test_another_name_is_forwarded_verbatim_and_its_reply_spoken(listener):
101
145
  post, ran, pictures = listener
102
146
  assert post({"cmd": "set PLTR | daily", "where": "mac-mini"}) == "Showing PLTR at daily. Good luck."
@@ -113,11 +157,9 @@ def test_an_unknown_name_is_spoken_back_with_the_owners_names(listener):
113
157
 
114
158
  # -- forward.route -----------------------------------------------------------------
115
159
 
116
- def test_the_operator_saying_self_runs_here_and_remembers_the_name(operator):
117
- config.update(display_label=None)
160
+ def test_the_operator_saying_self_runs_here(operator):
118
161
  StubOperator.answer = (200, {"self": True, "display": "Desk"})
119
162
  assert forward.route("read", "desk") is None
120
- assert config.load()["display_label"] == "Desk"
121
163
 
122
164
 
123
165
  def test_an_offline_display_is_said_plainly(operator):
@@ -136,11 +178,6 @@ def test_an_unreachable_operator_is_an_error_not_an_exception(operator):
136
178
  assert forward.route("read", "attic") == "ERR I could not reach the operator."
137
179
 
138
180
 
139
- @pytest.mark.parametrize("said", ["Mac mini", "mac-mini", "macmini", "MAC_MINI", "mac.mini"])
140
- def test_the_name_rule_is_the_operators(said):
141
- assert forward.display_key(said) == "macmini"
142
-
143
-
144
181
  # -- the Shortcut ------------------------------------------------------------------
145
182
 
146
183
  def test_the_shortcut_asks_where_after_scale_and_sends_it_beside_cmd():
@@ -153,3 +190,13 @@ def test_the_shortcut_asks_where_after_scale_and_sends_it_beside_cmd():
153
190
  assert fields["cmd"]["string"] == "set  | ", "the vocab grammar is unchanged"
154
191
  where = actions[prompts.index("Where?")]["WFWorkflowActionParameters"]["UUID"]
155
192
  assert fields["where"]["attachmentsByRange"]["{0, 1}"]["OutputUUID"] == where
193
+
194
+
195
+ def test_a_forwarded_command_carries_no_newlines_from_the_shortcut(listener):
196
+ post, ran, pictures = listener
197
+ # The Shortcut splices this Mac's own replies ("PLTR\n", "half\n") into the command.
198
+ post({"cmd": "set PLTR\n | half\n", "where": "Office\n"})
199
+ _, sent = StubOperator.seen[-1]
200
+ assert sent["cmd"] == "set PLTR | half"
201
+ assert sent["display"] == "Office"
202
+ assert ran == [] and pictures == []
@@ -0,0 +1,23 @@
1
+ """The one log line per request: enough to diagnose an ERR, nothing secret."""
2
+
3
+ from chartremotely import requestlog
4
+
5
+
6
+ def test_a_line_carries_time_source_verb_and_where_only():
7
+ line = requestlog.line("serve", "set PLTR | daily", "mac mini", "Showing PLTR at daily.")
8
+ stamp, rest = line.split(" ", 1)
9
+ assert stamp[:4].isdigit() and "T" in stamp
10
+ assert rest == "serve verb=set where='mac mini'"
11
+
12
+
13
+ def test_an_err_reply_is_kept_truncated_and_on_one_line():
14
+ reply = "ERR no\nsuch display " + "x" * 500
15
+ line = requestlog.line("relay", "read", "", reply)
16
+ assert "\n" not in line
17
+ logged = line.split("reply=", 1)[1]
18
+ assert logged.startswith("'ERR no such display") and len(logged) == requestlog.ERR_MAX + 2
19
+
20
+
21
+ def test_where_is_truncated_and_an_empty_command_is_a_dash():
22
+ line = requestlog.line("serve", "", "w" * 100, "ERR empty")
23
+ assert "verb=- " in line and f"where='{'w' * requestlog.WHERE_MAX}'" in line
@@ -43,6 +43,15 @@ def test_the_template_carries_placeholders_and_no_ones_address_or_token():
43
43
  assert not re.search(r"<string>[A-Za-z0-9_-]{40,}</string>", raw)
44
44
 
45
45
 
46
+ def test_every_question_takes_one_line_so_return_answers_it():
47
+ actions = plistlib.loads(shortcut.template())["WFWorkflowActions"]
48
+ asks = [a["WFWorkflowActionParameters"] for a in actions
49
+ if a["WFWorkflowActionIdentifier"].endswith(".ask")]
50
+ assert asks, "the template asks nothing"
51
+ for ask in asks:
52
+ assert ask.get("WFAskActionAllowsMultilineText") is False, ask.get("WFAskActionPrompt")
53
+
54
+
46
55
  def test_filling_the_template_changes_the_placeholders_and_nothing_else():
47
56
  raw = shortcut.template()
48
57
  filled = shortcut.fill(raw, "https://mac.example.ts.net/chart", "T0KEN")
@@ -72,6 +81,55 @@ def test_plist_values_are_escaped():
72
81
  assert plistlib.loads(text.encode())["ProgramArguments"] == ["/p<q>"]
73
82
 
74
83
 
84
+ @pytest.fixture
85
+ def fake_launchd(monkeypatch, tmp_path):
86
+ """A launchd that lets go of a label only after a few polls, as the real one does."""
87
+ state = {"loaded": True, "polls_until_gone": 3, "bootstrap_failures": 0, "calls": []}
88
+
89
+ def loaded(label):
90
+ if state["loaded"] and state.get("booted_out"):
91
+ state["polls_until_gone"] -= 1
92
+ if state["polls_until_gone"] <= 0:
93
+ state["loaded"] = False
94
+ return state["loaded"]
95
+
96
+ def run(args, **kwargs):
97
+ state["calls"].append(args[1])
98
+ if args[1] == "bootout":
99
+ state["booted_out"] = True
100
+ return types.SimpleNamespace(returncode=0, stdout="", stderr="")
101
+ if state["loaded"] or state["bootstrap_failures"] > 0:
102
+ state["bootstrap_failures"] = max(0, state["bootstrap_failures"] - 1)
103
+ return types.SimpleNamespace(returncode=5, stdout="", stderr="Bootstrap failed: 5: Input/output error")
104
+ state["loaded"] = True
105
+ return types.SimpleNamespace(returncode=0, stdout="", stderr="")
106
+
107
+ monkeypatch.setattr(services, "AGENTS_DIR", tmp_path)
108
+ monkeypatch.setattr(services, "loaded", loaded)
109
+ monkeypatch.setattr(services.subprocess, "run", run)
110
+ monkeypatch.setattr(services.time, "sleep", lambda s: None)
111
+ monkeypatch.setattr(services, "executable", lambda: "/usr/local/bin/chartremotely")
112
+ return state
113
+
114
+
115
+ def test_reinstalling_a_running_service_waits_for_launchd_to_let_go(fake_launchd):
116
+ services.install("serve")
117
+ assert fake_launchd["calls"] == ["bootout", "bootstrap"]
118
+ assert fake_launchd["loaded"] is True
119
+
120
+
121
+ def test_a_bootstrap_refused_while_launchd_settles_is_tried_again(fake_launchd):
122
+ fake_launchd["bootstrap_failures"] = 2
123
+ services.install("serve")
124
+ assert fake_launchd["calls"].count("bootstrap") == 3
125
+
126
+
127
+ def test_a_bootstrap_that_never_succeeds_stops_setup_with_launchds_reason(fake_launchd, monkeypatch):
128
+ fake_launchd["bootstrap_failures"] = 10**6
129
+ with pytest.raises(services.ServiceError, match="Input/output error"):
130
+ services.install("serve", settle=0)
131
+
132
+
75
133
  # -- the Keychain and the clipboard ---------------------------------------------
76
134
 
77
135
  @pytest.fixture
@@ -203,7 +261,6 @@ def test_a_made_key_never_reaches_the_agents_config(monkeypatch, tmp_path):
203
261
  stored = (tmp_path / "config.json").read_text()
204
262
  assert NSEC not in stored and "proof-for" not in stored
205
263
  assert json.loads(stored)["agent_id"] == "a1"
206
- assert json.loads(stored)["display_label"] == "Desk", "so \"Where? Desk\" stays on this Mac"
207
264
  assert kept == [NPUB], "the made key is handed to the human's Keychain"
208
265
  assert built == ["https://mac.example.ts.net/chart"]
209
266
  assert not any(NSEC in s for s in said), "the key is never printed"
@@ -0,0 +1,75 @@
1
+ """The CHANGELOG stamp the Release workflow runs after an automatic release."""
2
+
3
+ import importlib.util
4
+ from pathlib import Path
5
+
6
+ _SCRIPT = Path(__file__).resolve().parents[1] / ".github" / "scripts" / "stamp_changelog.py"
7
+ _spec = importlib.util.spec_from_file_location("stamp_changelog", _SCRIPT)
8
+ stamp_changelog = importlib.util.module_from_spec(_spec)
9
+ _spec.loader.exec_module(stamp_changelog)
10
+
11
+ HEAD = "# Changelog\n\nIntro.\n\n"
12
+ OLD = "## [0.1.0] - 2026-01-01\n\n### Added\n- First.\n"
13
+
14
+
15
+ def _log(unreleased: str) -> str:
16
+ return f"{HEAD}## [Unreleased]\n{unreleased}\n{OLD}"
17
+
18
+
19
+ def _release(version, text, previous=""):
20
+ items = stamp_changelog.added(
21
+ stamp_changelog.unreleased_entries(text), stamp_changelog.unreleased_entries(previous)
22
+ )
23
+ return (version, "2026-02-02", items)
24
+
25
+
26
+ def test_moves_shipped_lines_under_a_dated_section_keeping_subsections():
27
+ text = _log("\n### Fixed\n- A fix.\n\n### Added\n- A thing,\n wrapped.\n")
28
+ out = stamp_changelog.stamp(text, [_release("0.1.1", text)])
29
+ assert out == (
30
+ f"{HEAD}## [Unreleased]\n\n"
31
+ "## [0.1.1] - 2026-02-02\n\n### Fixed\n- A fix.\n\n### Added\n- A thing,\n wrapped.\n\n"
32
+ f"{OLD}"
33
+ )
34
+
35
+
36
+ def test_a_second_run_changes_nothing():
37
+ text = _log("\n### Fixed\n- A fix.\n")
38
+ once = stamp_changelog.stamp(text, [_release("0.1.1", text)])
39
+ assert stamp_changelog.stamp(once, [_release("0.1.1", text)]) == once
40
+
41
+
42
+ def test_empty_unreleased_is_left_alone():
43
+ text = _log("")
44
+ assert stamp_changelog.stamp(text, [_release("0.1.1", text)]) == text
45
+
46
+
47
+ def test_each_release_takes_only_what_it_added_and_later_lines_stay():
48
+ at_first = _log("\n### Changed\n- One.\n")
49
+ at_second = _log("\n### Changed\n- One.\n- Two.\n")
50
+ main = _log("\n### Fixed\n- Three.\n\n### Changed\n- One.\n- Two.\n")
51
+ out = stamp_changelog.stamp(
52
+ main, [_release("0.1.1", at_first), _release("0.1.2", at_second, at_first)]
53
+ )
54
+ assert out == (
55
+ f"{HEAD}## [Unreleased]\n\n### Fixed\n- Three.\n\n"
56
+ "## [0.1.2] - 2026-02-02\n\n### Changed\n- Two.\n\n"
57
+ "## [0.1.1] - 2026-02-02\n\n### Changed\n- One.\n\n"
58
+ f"{OLD}"
59
+ )
60
+
61
+
62
+ def test_repeated_subsections_merge_and_reworded_lines_stay_unreleased():
63
+ at_release = _log("\n### Changed\n- A.\n\n### Changed\n- B.\n- Old wording.\n")
64
+ main = _log("\n### Changed\n- A.\n\n### Changed\n- B.\n- New wording.\n")
65
+ out = stamp_changelog.stamp(main, [_release("0.1.1", at_release)])
66
+ assert out == (
67
+ f"{HEAD}## [Unreleased]\n\n### Changed\n- New wording.\n\n"
68
+ "## [0.1.1] - 2026-02-02\n\n### Changed\n- A.\n- B.\n\n"
69
+ f"{OLD}"
70
+ )
71
+
72
+
73
+ def test_a_version_that_already_has_a_section_is_skipped():
74
+ text = _log("\n### Fixed\n- A fix.\n")
75
+ assert stamp_changelog.stamp(text, [_release("0.1.0", text)]) == text
File without changes
File without changes