chartremotely 0.2.3__tar.gz → 0.2.4__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.
- {chartremotely-0.2.3 → chartremotely-0.2.4}/PKG-INFO +17 -1
- {chartremotely-0.2.3 → chartremotely-0.2.4}/README.md +16 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/assets/shortcut-template.plist +51 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/config.py +2 -0
- chartremotely-0.2.4/chartremotely/forward.py +90 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/server.py +19 -7
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/setup.py +2 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely.egg-info/PKG-INFO +17 -1
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely.egg-info/SOURCES.txt +2 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/pyproject.toml +1 -1
- chartremotely-0.2.4/tests/test_forward.py +155 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/tests/test_relay.py +1 -1
- {chartremotely-0.2.3 → chartremotely-0.2.4}/tests/test_setup.py +1 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/LICENSE +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/__init__.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/answer.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/ax.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/cli.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/doctor.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/guilock.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/keystore.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/layout.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/mcpclient.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/push.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/registry.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/relay.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/resolve.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/scales.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/services.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/shortcut.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/snapshot.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/studies.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/symbol.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/tailnet.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/timeframe.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/vocab.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely/window.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely.egg-info/dependency_links.txt +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely.egg-info/entry_points.txt +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely.egg-info/requires.txt +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/chartremotely.egg-info/top_level.txt +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/setup.cfg +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/tests/test_push.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/tests/test_resolve.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/tests/test_snapshot.py +0 -0
- {chartremotely-0.2.3 → chartremotely-0.2.4}/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
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Remote control of desktop charting applications on macOS
|
|
5
5
|
Author: Lonnie VanZandt
|
|
6
6
|
License:
|
|
@@ -313,6 +313,22 @@ 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, 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.
|
|
331
|
+
|
|
316
332
|
## Running it as a service
|
|
317
333
|
|
|
318
334
|
`chartremotely setup` installs both transports as launchd agents
|
|
@@ -89,6 +89,22 @@ 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, 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.
|
|
107
|
+
|
|
92
108
|
## Running it as a service
|
|
93
109
|
|
|
94
110
|
`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,90 @@
|
|
|
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 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.
|
|
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 re
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
|
|
20
|
+
from . import config
|
|
21
|
+
|
|
22
|
+
ERR = "ERR "
|
|
23
|
+
#: Longer than the operator's own wait on the target, so its 504 arrives first.
|
|
24
|
+
TIMEOUT = 35.0
|
|
25
|
+
|
|
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
|
+
def is_here(where: str, cfg: dict) -> bool:
|
|
35
|
+
"""Whether ``where`` means this Mac: empty, its display name, or its agent_id."""
|
|
36
|
+
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)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _post(url: str, payload: dict) -> tuple[int, dict]:
|
|
44
|
+
"""POST JSON; the status and the JSON body, for error statuses too."""
|
|
45
|
+
request = urllib.request.Request(
|
|
46
|
+
url, data=json.dumps(payload).encode(), method="POST",
|
|
47
|
+
headers={"Content-Type": "application/json"})
|
|
48
|
+
try:
|
|
49
|
+
with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
|
|
50
|
+
status, raw = response.status, response.read()
|
|
51
|
+
except urllib.error.HTTPError as exc:
|
|
52
|
+
status, raw = exc.code, exc.read()
|
|
53
|
+
try:
|
|
54
|
+
body = json.loads(raw.decode("utf-8", "replace") or "{}")
|
|
55
|
+
except ValueError:
|
|
56
|
+
body = {}
|
|
57
|
+
return status, body if isinstance(body, dict) else {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def route(command: str, where: str, cfg: dict | None = None) -> str | None:
|
|
61
|
+
"""The reply of the display named ``where``, or None when that display is this Mac.
|
|
62
|
+
|
|
63
|
+
Never raises: every failure is a short ``ERR`` sentence to be spoken.
|
|
64
|
+
"""
|
|
65
|
+
cfg = cfg or config.load()
|
|
66
|
+
if is_here(where, cfg):
|
|
67
|
+
return None
|
|
68
|
+
base = (cfg.get("operator_url") or "").rstrip("/")
|
|
69
|
+
agent_id, secret = cfg.get("agent_id"), cfg.get("agent_secret")
|
|
70
|
+
if not (base and agent_id and secret):
|
|
71
|
+
return ERR + "This Mac is not paired, so it can only drive its own chart."
|
|
72
|
+
wanted = where.strip()
|
|
73
|
+
try:
|
|
74
|
+
status, body = _post(f"{base}/agent/forward",
|
|
75
|
+
{"agent_id": agent_id, "secret": secret,
|
|
76
|
+
"display": wanted, "cmd": command})
|
|
77
|
+
except (urllib.error.URLError, TimeoutError, OSError):
|
|
78
|
+
return ERR + "I could not reach the operator."
|
|
79
|
+
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
|
+
return None
|
|
84
|
+
if status == 200:
|
|
85
|
+
return str(body.get("reply") or "")
|
|
86
|
+
if status == 404:
|
|
87
|
+
names = [str(n) for n in body.get("displays") or []]
|
|
88
|
+
yours = f" Yours: {', '.join(names)}." if names else ""
|
|
89
|
+
return ERR + f'No display named "{wanted}".{yours}'
|
|
90
|
+
return ERR + str(body.get("error") or f"the operator answered {status}")
|
|
@@ -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
|
|
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
|
-
|
|
49
|
-
|
|
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,10 +60,19 @@ 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
|
+
return self._reply(200, elsewhere)
|
|
64
76
|
result = answer(request)
|
|
65
77
|
self._reply(200, result.reply)
|
|
66
78
|
push.after_reply(request, result.reply, result.symbol)
|
|
@@ -157,6 +157,8 @@ 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)
|
|
160
162
|
say(f"Paired as “{label}”.")
|
|
161
163
|
|
|
162
164
|
# 3. Tailscale.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chartremotely
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Remote control of desktop charting applications on macOS
|
|
5
5
|
Author: Lonnie VanZandt
|
|
6
6
|
License:
|
|
@@ -313,6 +313,22 @@ 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, 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.
|
|
331
|
+
|
|
316
332
|
## Running it as a service
|
|
317
333
|
|
|
318
334
|
`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
|
|
@@ -34,6 +35,7 @@ chartremotely.egg-info/entry_points.txt
|
|
|
34
35
|
chartremotely.egg-info/requires.txt
|
|
35
36
|
chartremotely.egg-info/top_level.txt
|
|
36
37
|
chartremotely/assets/shortcut-template.plist
|
|
38
|
+
tests/test_forward.py
|
|
37
39
|
tests/test_push.py
|
|
38
40
|
tests/test_relay.py
|
|
39
41
|
tests/test_resolve.py
|
|
@@ -0,0 +1,155 @@
|
|
|
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", display_label="Desk")
|
|
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", ["Desk", "desk", " DESK ", "a1"])
|
|
94
|
+
def test_this_macs_own_name_runs_here(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
|
+
def test_another_name_is_forwarded_verbatim_and_its_reply_spoken(listener):
|
|
101
|
+
post, ran, pictures = listener
|
|
102
|
+
assert post({"cmd": "set PLTR | daily", "where": "mac-mini"}) == "Showing PLTR at daily. Good luck."
|
|
103
|
+
assert StubOperator.seen == [("/agent/forward", {
|
|
104
|
+
"agent_id": "a1", "secret": "s1", "display": "mac-mini", "cmd": "set PLTR | daily"})]
|
|
105
|
+
assert ran == [] and pictures == [], "the target runs it and pushes its own picture"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_an_unknown_name_is_spoken_back_with_the_owners_names(listener):
|
|
109
|
+
post, _, _ = listener
|
|
110
|
+
StubOperator.answer = (404, {"error": "no display named 'kitchen'", "displays": ["Desk", "Mac Mini"]})
|
|
111
|
+
assert post({"cmd": "read", "where": "kitchen"}) == 'ERR No display named "kitchen". Yours: Desk, Mac Mini.'
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# -- forward.route -----------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def test_the_operator_saying_self_runs_here_and_remembers_the_name(operator):
|
|
117
|
+
config.update(display_label=None)
|
|
118
|
+
StubOperator.answer = (200, {"self": True, "display": "Desk"})
|
|
119
|
+
assert forward.route("read", "desk") is None
|
|
120
|
+
assert config.load()["display_label"] == "Desk"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_an_offline_display_is_said_plainly(operator):
|
|
124
|
+
StubOperator.answer = (503, {"error": "attic is offline", "display": "attic"})
|
|
125
|
+
assert forward.route("read", "attic") == "ERR attic is offline"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_an_unpaired_mac_can_only_drive_itself(operator):
|
|
129
|
+
config.update(agent_id=None, agent_secret=None)
|
|
130
|
+
assert forward.route("read", "attic").startswith("ERR This Mac is not paired")
|
|
131
|
+
assert forward.route("read", "") is None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_an_unreachable_operator_is_an_error_not_an_exception(operator):
|
|
135
|
+
config.update(operator_url="http://127.0.0.1:1")
|
|
136
|
+
assert forward.route("read", "attic") == "ERR I could not reach the operator."
|
|
137
|
+
|
|
138
|
+
|
|
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
|
+
# -- the Shortcut ------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def test_the_shortcut_asks_where_after_scale_and_sends_it_beside_cmd():
|
|
147
|
+
actions = plistlib.loads(shortcut.template())["WFWorkflowActions"]
|
|
148
|
+
prompts = [a["WFWorkflowActionParameters"].get("WFAskActionPrompt") for a in actions]
|
|
149
|
+
assert prompts.index("Where?") > prompts.index("What scale?")
|
|
150
|
+
final = actions[prompts.index("Where?") + 1]["WFWorkflowActionParameters"]
|
|
151
|
+
fields = {i["WFKey"]["Value"]["string"]: i["WFValue"]["Value"]
|
|
152
|
+
for i in final["WFJSONValues"]["Value"]["WFDictionaryFieldValueItems"]}
|
|
153
|
+
assert fields["cmd"]["string"] == "set  | ", "the vocab grammar is unchanged"
|
|
154
|
+
where = actions[prompts.index("Where?")]["WFWorkflowActionParameters"]["UUID"]
|
|
155
|
+
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):
|
|
@@ -203,6 +203,7 @@ def test_a_made_key_never_reaches_the_agents_config(monkeypatch, tmp_path):
|
|
|
203
203
|
stored = (tmp_path / "config.json").read_text()
|
|
204
204
|
assert NSEC not in stored and "proof-for" not in stored
|
|
205
205
|
assert json.loads(stored)["agent_id"] == "a1"
|
|
206
|
+
assert json.loads(stored)["display_label"] == "Desk", "so \"Where? Desk\" stays on this Mac"
|
|
206
207
|
assert kept == [NPUB], "the made key is handed to the human's Keychain"
|
|
207
208
|
assert built == ["https://mac.example.ts.net/chart"]
|
|
208
209
|
assert not any(NSEC in s for s in said), "the key is never printed"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|