chartremotely 0.2.3__tar.gz → 0.2.5__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 (48) hide show
  1. {chartremotely-0.2.3 → chartremotely-0.2.5}/PKG-INFO +22 -1
  2. {chartremotely-0.2.3 → chartremotely-0.2.5}/README.md +21 -0
  3. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/assets/shortcut-template.plist +51 -0
  4. chartremotely-0.2.5/chartremotely/forward.py +75 -0
  5. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/relay.py +2 -1
  6. chartremotely-0.2.5/chartremotely/requestlog.py +41 -0
  7. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/server.py +25 -8
  8. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/services.py +23 -4
  9. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely.egg-info/PKG-INFO +22 -1
  10. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely.egg-info/SOURCES.txt +4 -0
  11. {chartremotely-0.2.3 → chartremotely-0.2.5}/pyproject.toml +1 -1
  12. chartremotely-0.2.5/tests/test_forward.py +178 -0
  13. {chartremotely-0.2.3 → chartremotely-0.2.5}/tests/test_relay.py +1 -1
  14. chartremotely-0.2.5/tests/test_requestlog.py +23 -0
  15. {chartremotely-0.2.3 → chartremotely-0.2.5}/tests/test_setup.py +49 -0
  16. {chartremotely-0.2.3 → chartremotely-0.2.5}/LICENSE +0 -0
  17. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/__init__.py +0 -0
  18. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/answer.py +0 -0
  19. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/ax.py +0 -0
  20. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/cli.py +0 -0
  21. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/config.py +0 -0
  22. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/doctor.py +0 -0
  23. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/guilock.py +0 -0
  24. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/keystore.py +0 -0
  25. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/layout.py +0 -0
  26. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/mcpclient.py +0 -0
  27. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/push.py +0 -0
  28. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/registry.py +0 -0
  29. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/resolve.py +0 -0
  30. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/scales.py +0 -0
  31. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/setup.py +0 -0
  32. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/shortcut.py +0 -0
  33. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/snapshot.py +0 -0
  34. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/studies.py +0 -0
  35. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/symbol.py +0 -0
  36. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/tailnet.py +0 -0
  37. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/timeframe.py +0 -0
  38. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/vocab.py +0 -0
  39. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely/window.py +0 -0
  40. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely.egg-info/dependency_links.txt +0 -0
  41. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely.egg-info/entry_points.txt +0 -0
  42. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely.egg-info/requires.txt +0 -0
  43. {chartremotely-0.2.3 → chartremotely-0.2.5}/chartremotely.egg-info/top_level.txt +0 -0
  44. {chartremotely-0.2.3 → chartremotely-0.2.5}/setup.cfg +0 -0
  45. {chartremotely-0.2.3 → chartremotely-0.2.5}/tests/test_push.py +0 -0
  46. {chartremotely-0.2.3 → chartremotely-0.2.5}/tests/test_resolve.py +0 -0
  47. {chartremotely-0.2.3 → chartremotely-0.2.5}/tests/test_snapshot.py +0 -0
  48. {chartremotely-0.2.3 → chartremotely-0.2.5}/tests/test_timeframe.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -313,6 +313,27 @@ certificate to keep alive. Pairing alone does not start it: a display that is
313
313
  paired but has no `relay` running reports `connected: false` and silently
314
314
  ignores everything sent to it.
315
315
 
316
+ ## Voice
317
+
318
+ "Hey Siri, ChartRemotely" asks three questions:
319
+
320
+ 1. **Which company?** — a spoken name or a spelled ticker.
321
+ 2. **What scale?** — a time frame mnemonic (below), or "as is".
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.
336
+
316
337
  ## Running it as a service
317
338
 
318
339
  `chartremotely setup` installs both transports as launchd agents
@@ -89,6 +89,27 @@ certificate to keep alive. Pairing alone does not start it: a display that is
89
89
  paired but has no `relay` running reports `connected: false` and silently
90
90
  ignores everything sent to it.
91
91
 
92
+ ## Voice
93
+
94
+ "Hey Siri, ChartRemotely" asks three questions:
95
+
96
+ 1. **Which company?** — a spoken name or a spelled ticker.
97
+ 2. **What scale?** — a time frame mnemonic (below), or "as is".
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.
112
+
92
113
  ## Running it as a service
93
114
 
94
115
  `chartremotely setup` installs both transports as launchd agents
@@ -1039,6 +1039,21 @@
1039
1039
  <integer>2</integer>
1040
1040
  </dict>
1041
1041
  </dict>
1042
+ <dict>
1043
+ <key>WFWorkflowActionIdentifier</key>
1044
+ <string>is.workflow.actions.ask</string>
1045
+ <key>WFWorkflowActionParameters</key>
1046
+ <dict>
1047
+ <key>CustomOutputName</key>
1048
+ <string>Where</string>
1049
+ <key>UUID</key>
1050
+ <string>6F0B7C51-2D4E-4A8B-9C3F-0E5D8A1B7C42</string>
1051
+ <key>WFAskActionPrompt</key>
1052
+ <string>Where?</string>
1053
+ <key>WFInputType</key>
1054
+ <string>Text</string>
1055
+ </dict>
1056
+ </dict>
1042
1057
  <dict>
1043
1058
  <key>WFWorkflowActionIdentifier</key>
1044
1059
  <string>is.workflow.actions.downloadurl</string>
@@ -1160,6 +1175,42 @@
1160
1175
  <string>WFTextTokenString</string>
1161
1176
  </dict>
1162
1177
  </dict>
1178
+ <dict>
1179
+ <key>WFItemType</key>
1180
+ <integer>0</integer>
1181
+ <key>WFKey</key>
1182
+ <dict>
1183
+ <key>Value</key>
1184
+ <dict>
1185
+ <key>string</key>
1186
+ <string>where</string>
1187
+ </dict>
1188
+ <key>WFSerializationType</key>
1189
+ <string>WFTextTokenString</string>
1190
+ </dict>
1191
+ <key>WFValue</key>
1192
+ <dict>
1193
+ <key>Value</key>
1194
+ <dict>
1195
+ <key>attachmentsByRange</key>
1196
+ <dict>
1197
+ <key>{0, 1}</key>
1198
+ <dict>
1199
+ <key>OutputName</key>
1200
+ <string>Where</string>
1201
+ <key>OutputUUID</key>
1202
+ <string>6F0B7C51-2D4E-4A8B-9C3F-0E5D8A1B7C42</string>
1203
+ <key>Type</key>
1204
+ <string>ActionOutput</string>
1205
+ </dict>
1206
+ </dict>
1207
+ <key>string</key>
1208
+ <string></string>
1209
+ </dict>
1210
+ <key>WFSerializationType</key>
1211
+ <string>WFTextTokenString</string>
1212
+ </dict>
1213
+ </dict>
1163
1214
  </array>
1164
1215
  </dict>
1165
1216
  <key>WFSerializationType</key>
@@ -0,0 +1,75 @@
1
+ """Send a spoken command on to another of the owner's displays.
2
+
3
+ The Shortcut asks "Where?" and passes the dictated answer through verbatim.
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
+
10
+ Stdlib only, like the relay, so it imports where the macOS drivers do not.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import urllib.error
17
+ import urllib.request
18
+
19
+ from . import config
20
+
21
+ ERR = "ERR "
22
+ #: Longer than the operator's own wait on the target, so its 504 arrives first.
23
+ TIMEOUT = 35.0
24
+
25
+ def is_here(where: str, cfg: dict) -> bool:
26
+ """Whether ``where`` means this Mac without asking: empty, or exactly its agent_id."""
27
+ wanted = (where or "").strip()
28
+ return not wanted or wanted == cfg.get("agent_id")
29
+
30
+
31
+ def _post(url: str, payload: dict) -> tuple[int, dict]:
32
+ """POST JSON; the status and the JSON body, for error statuses too."""
33
+ request = urllib.request.Request(
34
+ url, data=json.dumps(payload).encode(), method="POST",
35
+ headers={"Content-Type": "application/json"})
36
+ try:
37
+ with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
38
+ status, raw = response.status, response.read()
39
+ except urllib.error.HTTPError as exc:
40
+ status, raw = exc.code, exc.read()
41
+ try:
42
+ body = json.loads(raw.decode("utf-8", "replace") or "{}")
43
+ except ValueError:
44
+ body = {}
45
+ return status, body if isinstance(body, dict) else {}
46
+
47
+
48
+ def route(command: str, where: str, cfg: dict | None = None) -> str | None:
49
+ """The reply of the display named ``where``, or None when that display is this Mac.
50
+
51
+ Never raises: every failure is a short ``ERR`` sentence to be spoken.
52
+ """
53
+ cfg = cfg or config.load()
54
+ if is_here(where, cfg):
55
+ return None
56
+ base = (cfg.get("operator_url") or "").rstrip("/")
57
+ agent_id, secret = cfg.get("agent_id"), cfg.get("agent_secret")
58
+ if not (base and agent_id and secret):
59
+ return ERR + "This Mac is not paired, so it can only drive its own chart."
60
+ wanted = where.strip()
61
+ try:
62
+ status, body = _post(f"{base}/agent/forward",
63
+ {"agent_id": agent_id, "secret": secret,
64
+ "display": wanted, "cmd": command})
65
+ except (urllib.error.URLError, TimeoutError, OSError):
66
+ return ERR + "I could not reach the operator."
67
+ if status == 200 and body.get("self"):
68
+ return None
69
+ if status == 200:
70
+ return str(body.get("reply") or "")
71
+ if status == 404:
72
+ names = [str(n) for n in body.get("displays") or []]
73
+ yours = f" Yours: {', '.join(names)}." if names else ""
74
+ return ERR + f'No display named "{wanted}".{yours}'
75
+ return ERR + str(body.get("error") or f"the operator answered {status}")
@@ -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, push
20
+ from . import config, forward, push, requestlog
21
21
  from .vocab import answer
22
22
 
23
23
 
@@ -42,13 +42,16 @@ class Handler(BaseHTTPRequestHandler):
42
42
  return self._reply(403, "ERR forbidden")
43
43
  length = int(self.headers.get("Content-Length") or 0)
44
44
  raw = self.rfile.read(length).decode("utf-8", "replace").strip()
45
+ where = ""
45
46
  # Shortcuts posts JSON most cleanly; curl and scripts post raw text.
46
47
  if raw.startswith("{"):
47
48
  try:
48
- raw = str(json.loads(raw).get("cmd") or "").strip()
49
- except ValueError:
49
+ body = json.loads(raw)
50
+ raw = str(body.get("cmd") or "").strip()
51
+ where = str(body.get("where") or "").strip()
52
+ except (ValueError, AttributeError):
50
53
  return self._reply(400, "ERR bad JSON")
51
- self._answer(raw)
54
+ self._answer(raw, where)
52
55
 
53
56
  def do_GET(self) -> None:
54
57
  parsed = urllib.parse.urlparse(self.path)
@@ -57,16 +60,30 @@ class Handler(BaseHTTPRequestHandler):
57
60
  query = urllib.parse.parse_qs(parsed.query)
58
61
  if not self._authorised((query.get("t") or [None])[0]):
59
62
  return self._reply(403, "ERR forbidden")
60
- self._answer((query.get("cmd") or [""])[0].strip())
63
+ self._answer((query.get("cmd") or [""])[0].strip(),
64
+ (query.get("where") or [""])[0].strip())
61
65
 
62
- def _answer(self, request: str) -> None:
63
- """Reply first; only then schedule the picture of a changed chart."""
66
+ def _answer(self, request: str, where: str = "") -> None:
67
+ """Reply first; only then schedule the picture of a changed chart.
68
+
69
+ ``where`` names the display the command is for, as dictated. Another
70
+ display's command is forwarded and its reply spoken; that display
71
+ pushes its own picture, so none is scheduled here.
72
+ """
73
+ elsewhere = forward.route(request, where) if where and request else None
74
+ if elsewhere is not None:
75
+ self._reply(200, elsewhere)
76
+ return requestlog.request("serve", request, where, elsewhere)
64
77
  result = answer(request)
65
78
  self._reply(200, result.reply)
79
+ requestlog.request("serve", request, where, result.reply)
66
80
  push.after_reply(request, result.reply, result.symbol)
67
81
 
68
82
  def log_message(self, *args) -> None:
69
- """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
+ """
70
87
 
71
88
 
72
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -313,6 +313,27 @@ certificate to keep alive. Pairing alone does not start it: a display that is
313
313
  paired but has no `relay` running reports `connected: false` and silently
314
314
  ignores everything sent to it.
315
315
 
316
+ ## Voice
317
+
318
+ "Hey Siri, ChartRemotely" asks three questions:
319
+
320
+ 1. **Which company?** — a spoken name or a spelled ticker.
321
+ 2. **What scale?** — a time frame mnemonic (below), or "as is".
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.
336
+
316
337
  ## Running it as a service
317
338
 
318
339
  `chartremotely setup` installs both transports as launchd agents
@@ -7,6 +7,7 @@ chartremotely/ax.py
7
7
  chartremotely/cli.py
8
8
  chartremotely/config.py
9
9
  chartremotely/doctor.py
10
+ chartremotely/forward.py
10
11
  chartremotely/guilock.py
11
12
  chartremotely/keystore.py
12
13
  chartremotely/layout.py
@@ -14,6 +15,7 @@ chartremotely/mcpclient.py
14
15
  chartremotely/push.py
15
16
  chartremotely/registry.py
16
17
  chartremotely/relay.py
18
+ chartremotely/requestlog.py
17
19
  chartremotely/resolve.py
18
20
  chartremotely/scales.py
19
21
  chartremotely/server.py
@@ -34,8 +36,10 @@ chartremotely.egg-info/entry_points.txt
34
36
  chartremotely.egg-info/requires.txt
35
37
  chartremotely.egg-info/top_level.txt
36
38
  chartremotely/assets/shortcut-template.plist
39
+ tests/test_forward.py
37
40
  tests/test_push.py
38
41
  tests/test_relay.py
42
+ tests/test_requestlog.py
39
43
  tests/test_resolve.py
40
44
  tests/test_setup.py
41
45
  tests/test_snapshot.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "chartremotely"
3
- version = "0.2.3"
3
+ version = "0.2.5"
4
4
  description = "Remote control of desktop charting applications on macOS"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -0,0 +1,178 @@
1
+ """Where?: this Mac, or another of the owner's displays through the operator.
2
+
3
+ The operator is a stub HTTP server and vocab is a stand-in, so this runs on
4
+ Linux without the macOS drivers.
5
+ """
6
+
7
+ import importlib
8
+ import json
9
+ import plistlib
10
+ import sys
11
+ import threading
12
+ import types
13
+ import urllib.request
14
+ from http.server import BaseHTTPRequestHandler, HTTPServer
15
+ from typing import ClassVar
16
+
17
+ import pytest
18
+
19
+ from chartremotely import config, forward, shortcut
20
+ from chartremotely.answer import Answer
21
+
22
+
23
+ class StubOperator(BaseHTTPRequestHandler):
24
+ """``/agent/forward`` answering with whatever a test queues."""
25
+
26
+ answer: ClassVar[tuple[int, dict]] = (200, {})
27
+ seen: ClassVar[list] = []
28
+
29
+ def do_POST(self):
30
+ body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)))
31
+ type(self).seen.append((self.path, body))
32
+ status, payload = type(self).answer
33
+ raw = json.dumps(payload).encode()
34
+ self.send_response(status)
35
+ self.send_header("Content-Type", "application/json")
36
+ self.send_header("Content-Length", str(len(raw)))
37
+ self.end_headers()
38
+ self.wfile.write(raw)
39
+
40
+ def log_message(self, *args):
41
+ pass
42
+
43
+
44
+ @pytest.fixture
45
+ def operator(monkeypatch, tmp_path):
46
+ monkeypatch.setattr(config, "CONFIG_DIR", tmp_path)
47
+ monkeypatch.setattr(config, "CONFIG_PATH", tmp_path / "config.json")
48
+ StubOperator.seen = []
49
+ StubOperator.answer = (200, {"display": "Mac Mini", "reply": "Showing PLTR at daily. Good luck."})
50
+ server = HTTPServer(("127.0.0.1", 0), StubOperator)
51
+ threading.Thread(target=server.serve_forever, daemon=True).start()
52
+ base = f"http://127.0.0.1:{server.server_address[1]}"
53
+ config.update(operator_url=base, agent_id="a1", agent_secret="s1")
54
+ yield base
55
+ server.shutdown()
56
+
57
+
58
+ @pytest.fixture
59
+ def listener(operator, monkeypatch):
60
+ """The Shortcut's listener, with a stand-in vocab. Returns (post, ran, pictures)."""
61
+ ran, pictures = [], []
62
+ fake_vocab = types.ModuleType("chartremotely.vocab")
63
+ fake_vocab.answer = lambda cmd: ran.append(cmd) or Answer("Showing PLTR at daily. Good luck.", "PLTR")
64
+ monkeypatch.setitem(sys.modules, "chartremotely.vocab", fake_vocab)
65
+ monkeypatch.delitem(sys.modules, "chartremotely.server", raising=False)
66
+ server = importlib.import_module("chartremotely.server") # bound to the stand-in vocab
67
+ from chartremotely import push
68
+ monkeypatch.setattr(push, "after_reply", lambda *a: pictures.append(a))
69
+ server.Handler.token = "T0KEN"
70
+ http = HTTPServer(("127.0.0.1", 0), server.Handler)
71
+ threading.Thread(target=http.serve_forever, daemon=True).start()
72
+
73
+ def post(body):
74
+ request = urllib.request.Request(
75
+ f"http://127.0.0.1:{http.server_address[1]}/chart", data=json.dumps(body).encode(),
76
+ method="POST", headers={"Content-Type": "application/json", "X-Token": "T0KEN"})
77
+ with urllib.request.urlopen(request, timeout=10) as response:
78
+ return response.read().decode().strip()
79
+
80
+ yield post, ran, pictures
81
+ http.shutdown()
82
+
83
+
84
+ # -- the listener ----------------------------------------------------------------
85
+
86
+ def test_no_where_runs_here_exactly_as_before(listener):
87
+ post, ran, pictures = listener
88
+ assert post({"cmd": "set PLTR | daily"}) == "Showing PLTR at daily. Good luck."
89
+ assert ran == ["set PLTR | daily"] and len(pictures) == 1
90
+ assert StubOperator.seen == []
91
+
92
+
93
+ @pytest.mark.parametrize("said", ["", " ", "a1", " a1 "])
94
+ def test_blank_or_this_macs_agent_id_runs_here_without_asking(listener, said):
95
+ post, ran, _ = listener
96
+ post({"cmd": "set PLTR | daily", "where": said})
97
+ assert ran == ["set PLTR | daily"] and StubOperator.seen == []
98
+
99
+
100
+ @pytest.mark.parametrize("said", ["Desk", "desk", "mini mac", "A1"])
101
+ def test_every_name_is_matched_by_the_operator_not_here(listener, said):
102
+ post, ran, _ = listener
103
+ StubOperator.answer = (200, {"self": True, "display": "Desk"})
104
+ post({"cmd": "set PLTR | daily", "where": said})
105
+ assert [body["display"] for _, body in StubOperator.seen] == [said.strip()]
106
+ assert ran == ["set PLTR | daily"], "the operator said it is this Mac, so it runs here"
107
+
108
+
109
+ def test_is_here_is_blank_or_the_exact_agent_id():
110
+ cfg = {"agent_id": "a1"}
111
+ assert forward.is_here("", cfg) and forward.is_here(" a1 ", cfg)
112
+ assert not forward.is_here("A1", cfg) and not forward.is_here("Desk", cfg)
113
+ assert forward.is_here("", {}) and not forward.is_here("a1", {})
114
+
115
+
116
+ def test_each_request_is_logged_once_without_the_token_or_arguments(listener, capsys):
117
+ post, _, _ = listener
118
+ capsys.readouterr()
119
+ post({"cmd": "set PLTR | daily", "where": "desk"})
120
+ StubOperator.answer = (404, {"error": "no display named 'kitchen'", "displays": ["Desk"]})
121
+ post({"cmd": "read", "where": "kitchen"})
122
+ lines = capsys.readouterr().err.strip().splitlines()
123
+ assert len(lines) == 2
124
+ assert "serve verb=set where='desk'" in lines[0] and "reply=" not in lines[0]
125
+ assert "verb=read where='kitchen' reply='ERR No display named \"kitchen\". Yours: Desk.'" in lines[1]
126
+ for line in lines:
127
+ assert "T0KEN" not in line and "s1" not in line and "PLTR" not in line
128
+
129
+
130
+ def test_another_name_is_forwarded_verbatim_and_its_reply_spoken(listener):
131
+ post, ran, pictures = listener
132
+ assert post({"cmd": "set PLTR | daily", "where": "mac-mini"}) == "Showing PLTR at daily. Good luck."
133
+ assert StubOperator.seen == [("/agent/forward", {
134
+ "agent_id": "a1", "secret": "s1", "display": "mac-mini", "cmd": "set PLTR | daily"})]
135
+ assert ran == [] and pictures == [], "the target runs it and pushes its own picture"
136
+
137
+
138
+ def test_an_unknown_name_is_spoken_back_with_the_owners_names(listener):
139
+ post, _, _ = listener
140
+ StubOperator.answer = (404, {"error": "no display named 'kitchen'", "displays": ["Desk", "Mac Mini"]})
141
+ assert post({"cmd": "read", "where": "kitchen"}) == 'ERR No display named "kitchen". Yours: Desk, Mac Mini.'
142
+
143
+
144
+ # -- forward.route -----------------------------------------------------------------
145
+
146
+ def test_the_operator_saying_self_runs_here(operator):
147
+ StubOperator.answer = (200, {"self": True, "display": "Desk"})
148
+ assert forward.route("read", "desk") is None
149
+
150
+
151
+ def test_an_offline_display_is_said_plainly(operator):
152
+ StubOperator.answer = (503, {"error": "attic is offline", "display": "attic"})
153
+ assert forward.route("read", "attic") == "ERR attic is offline"
154
+
155
+
156
+ def test_an_unpaired_mac_can_only_drive_itself(operator):
157
+ config.update(agent_id=None, agent_secret=None)
158
+ assert forward.route("read", "attic").startswith("ERR This Mac is not paired")
159
+ assert forward.route("read", "") is None
160
+
161
+
162
+ def test_an_unreachable_operator_is_an_error_not_an_exception(operator):
163
+ config.update(operator_url="http://127.0.0.1:1")
164
+ assert forward.route("read", "attic") == "ERR I could not reach the operator."
165
+
166
+
167
+ # -- the Shortcut ------------------------------------------------------------------
168
+
169
+ def test_the_shortcut_asks_where_after_scale_and_sends_it_beside_cmd():
170
+ actions = plistlib.loads(shortcut.template())["WFWorkflowActions"]
171
+ prompts = [a["WFWorkflowActionParameters"].get("WFAskActionPrompt") for a in actions]
172
+ assert prompts.index("Where?") > prompts.index("What scale?")
173
+ final = actions[prompts.index("Where?") + 1]["WFWorkflowActionParameters"]
174
+ fields = {i["WFKey"]["Value"]["string"]: i["WFValue"]["Value"]
175
+ for i in final["WFJSONValues"]["Value"]["WFDictionaryFieldValueItems"]}
176
+ assert fields["cmd"]["string"] == "set  | ", "the vocab grammar is unchanged"
177
+ where = actions[prompts.index("Where?")]["WFWorkflowActionParameters"]["UUID"]
178
+ assert fields["where"]["attachmentsByRange"]["{0, 1}"]["OutputUUID"] == where
@@ -143,7 +143,7 @@ def test_the_wire_logic_imports_without_the_macos_drivers():
143
143
 
144
144
  forbidden = {"Quartz", "AppKit", "ApplicationServices", "CoreFoundation"}
145
145
  for name in ("relay", "resolve", "scales", "config", "registry", "snapshot", "push", "guilock",
146
- "mcpclient", "keystore", "services", "tailnet", "shortcut", "setup"):
146
+ "mcpclient", "keystore", "services", "tailnet", "shortcut", "setup", "forward"):
147
147
  tree = ast.parse(Path(f"chartremotely/{name}.py").read_text())
148
148
  for node in ast.walk(tree):
149
149
  if isinstance(node, ast.Import):
@@ -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
@@ -72,6 +72,55 @@ def test_plist_values_are_escaped():
72
72
  assert plistlib.loads(text.encode())["ProgramArguments"] == ["/p<q>"]
73
73
 
74
74
 
75
+ @pytest.fixture
76
+ def fake_launchd(monkeypatch, tmp_path):
77
+ """A launchd that lets go of a label only after a few polls, as the real one does."""
78
+ state = {"loaded": True, "polls_until_gone": 3, "bootstrap_failures": 0, "calls": []}
79
+
80
+ def loaded(label):
81
+ if state["loaded"] and state.get("booted_out"):
82
+ state["polls_until_gone"] -= 1
83
+ if state["polls_until_gone"] <= 0:
84
+ state["loaded"] = False
85
+ return state["loaded"]
86
+
87
+ def run(args, **kwargs):
88
+ state["calls"].append(args[1])
89
+ if args[1] == "bootout":
90
+ state["booted_out"] = True
91
+ return types.SimpleNamespace(returncode=0, stdout="", stderr="")
92
+ if state["loaded"] or state["bootstrap_failures"] > 0:
93
+ state["bootstrap_failures"] = max(0, state["bootstrap_failures"] - 1)
94
+ return types.SimpleNamespace(returncode=5, stdout="", stderr="Bootstrap failed: 5: Input/output error")
95
+ state["loaded"] = True
96
+ return types.SimpleNamespace(returncode=0, stdout="", stderr="")
97
+
98
+ monkeypatch.setattr(services, "AGENTS_DIR", tmp_path)
99
+ monkeypatch.setattr(services, "loaded", loaded)
100
+ monkeypatch.setattr(services.subprocess, "run", run)
101
+ monkeypatch.setattr(services.time, "sleep", lambda s: None)
102
+ monkeypatch.setattr(services, "executable", lambda: "/usr/local/bin/chartremotely")
103
+ return state
104
+
105
+
106
+ def test_reinstalling_a_running_service_waits_for_launchd_to_let_go(fake_launchd):
107
+ services.install("serve")
108
+ assert fake_launchd["calls"] == ["bootout", "bootstrap"]
109
+ assert fake_launchd["loaded"] is True
110
+
111
+
112
+ def test_a_bootstrap_refused_while_launchd_settles_is_tried_again(fake_launchd):
113
+ fake_launchd["bootstrap_failures"] = 2
114
+ services.install("serve")
115
+ assert fake_launchd["calls"].count("bootstrap") == 3
116
+
117
+
118
+ def test_a_bootstrap_that_never_succeeds_stops_setup_with_launchds_reason(fake_launchd, monkeypatch):
119
+ fake_launchd["bootstrap_failures"] = 10**6
120
+ with pytest.raises(services.ServiceError, match="Input/output error"):
121
+ services.install("serve", settle=0)
122
+
123
+
75
124
  # -- the Keychain and the clipboard ---------------------------------------------
76
125
 
77
126
  @pytest.fixture
File without changes
File without changes