chartremotely 0.2.4__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.
- {chartremotely-0.2.4 → chartremotely-0.2.5}/PKG-INFO +15 -10
- {chartremotely-0.2.4 → chartremotely-0.2.5}/README.md +14 -9
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/config.py +0 -2
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/forward.py +7 -22
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/relay.py +2 -1
- chartremotely-0.2.5/chartremotely/requestlog.py +41 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/server.py +8 -3
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/services.py +23 -4
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/setup.py +0 -2
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely.egg-info/PKG-INFO +15 -10
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely.egg-info/SOURCES.txt +2 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/pyproject.toml +1 -1
- {chartremotely-0.2.4 → chartremotely-0.2.5}/tests/test_forward.py +34 -11
- chartremotely-0.2.5/tests/test_requestlog.py +23 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/tests/test_setup.py +49 -1
- {chartremotely-0.2.4 → chartremotely-0.2.5}/LICENSE +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/__init__.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/answer.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/assets/shortcut-template.plist +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/ax.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/cli.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/doctor.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/guilock.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/keystore.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/layout.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/mcpclient.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/push.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/registry.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/resolve.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/scales.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/shortcut.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/snapshot.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/studies.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/symbol.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/tailnet.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/timeframe.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/vocab.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely/window.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely.egg-info/dependency_links.txt +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely.egg-info/entry_points.txt +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely.egg-info/requires.txt +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/chartremotely.egg-info/top_level.txt +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/setup.cfg +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/tests/test_push.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/tests/test_relay.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/tests/test_resolve.py +0 -0
- {chartremotely-0.2.4 → chartremotely-0.2.5}/tests/test_snapshot.py +0 -0
- {chartremotely-0.2.4 → 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
|
+
Version: 0.2.5
|
|
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
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
|
@@ -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
|
|
5
|
-
operator's ``/agent/forward``, which
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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,
|
|
26
|
+
"""Whether ``where`` means this Mac without asking: empty, or exactly its agent_id."""
|
|
36
27
|
wanted = (where or "").strip()
|
|
37
|
-
|
|
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]:
|
|
@@ -77,9 +65,6 @@ def route(command: str, where: str, cfg: dict | None = None) -> str | None:
|
|
|
77
65
|
except (urllib.error.URLError, TimeoutError, OSError):
|
|
78
66
|
return ERR + "I could not reach the operator."
|
|
79
67
|
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
68
|
return None
|
|
84
69
|
if status == 200:
|
|
85
70
|
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
|
-
|
|
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
|
|
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
|
-
|
|
78
|
-
|
|
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.
|
|
3
|
+
Version: 0.2.5
|
|
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
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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,6 +39,7 @@ 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
|
|
@@ -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"
|
|
53
|
+
config.update(operator_url=base, agent_id="a1", agent_secret="s1")
|
|
54
54
|
yield base
|
|
55
55
|
server.shutdown()
|
|
56
56
|
|
|
@@ -90,13 +90,43 @@ def test_no_where_runs_here_exactly_as_before(listener):
|
|
|
90
90
|
assert StubOperator.seen == []
|
|
91
91
|
|
|
92
92
|
|
|
93
|
-
@pytest.mark.parametrize("said", ["
|
|
94
|
-
def
|
|
93
|
+
@pytest.mark.parametrize("said", ["", " ", "a1", " a1 "])
|
|
94
|
+
def test_blank_or_this_macs_agent_id_runs_here_without_asking(listener, said):
|
|
95
95
|
post, ran, _ = listener
|
|
96
96
|
post({"cmd": "set PLTR | daily", "where": said})
|
|
97
97
|
assert ran == ["set PLTR | daily"] and StubOperator.seen == []
|
|
98
98
|
|
|
99
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
|
+
|
|
100
130
|
def test_another_name_is_forwarded_verbatim_and_its_reply_spoken(listener):
|
|
101
131
|
post, ran, pictures = listener
|
|
102
132
|
assert post({"cmd": "set PLTR | daily", "where": "mac-mini"}) == "Showing PLTR at daily. Good luck."
|
|
@@ -113,11 +143,9 @@ def test_an_unknown_name_is_spoken_back_with_the_owners_names(listener):
|
|
|
113
143
|
|
|
114
144
|
# -- forward.route -----------------------------------------------------------------
|
|
115
145
|
|
|
116
|
-
def
|
|
117
|
-
config.update(display_label=None)
|
|
146
|
+
def test_the_operator_saying_self_runs_here(operator):
|
|
118
147
|
StubOperator.answer = (200, {"self": True, "display": "Desk"})
|
|
119
148
|
assert forward.route("read", "desk") is None
|
|
120
|
-
assert config.load()["display_label"] == "Desk"
|
|
121
149
|
|
|
122
150
|
|
|
123
151
|
def test_an_offline_display_is_said_plainly(operator):
|
|
@@ -136,11 +164,6 @@ def test_an_unreachable_operator_is_an_error_not_an_exception(operator):
|
|
|
136
164
|
assert forward.route("read", "attic") == "ERR I could not reach the operator."
|
|
137
165
|
|
|
138
166
|
|
|
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
167
|
# -- the Shortcut ------------------------------------------------------------------
|
|
145
168
|
|
|
146
169
|
def test_the_shortcut_asks_where_after_scale_and_sends_it_beside_cmd():
|
|
@@ -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
|
|
@@ -203,7 +252,6 @@ def test_a_made_key_never_reaches_the_agents_config(monkeypatch, tmp_path):
|
|
|
203
252
|
stored = (tmp_path / "config.json").read_text()
|
|
204
253
|
assert NSEC not in stored and "proof-for" not in stored
|
|
205
254
|
assert json.loads(stored)["agent_id"] == "a1"
|
|
206
|
-
assert json.loads(stored)["display_label"] == "Desk", "so \"Where? Desk\" stays on this Mac"
|
|
207
255
|
assert kept == [NPUB], "the made key is handed to the human's Keychain"
|
|
208
256
|
assert built == ["https://mac.example.ts.net/chart"]
|
|
209
257
|
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
|