chartremotely 0.2.7__tar.gz → 0.2.9__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.7 → chartremotely-0.2.9}/PKG-INFO +26 -9
- {chartremotely-0.2.7 → chartremotely-0.2.9}/README.md +25 -8
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/answer.py +4 -2
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/assets/shortcut-template.plist +0 -11
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/config.py +3 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/keystore.py +42 -8
- chartremotely-0.2.9/chartremotely/patron.py +194 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/push.py +33 -11
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/relay.py +1 -1
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/requestlog.py +12 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/server.py +20 -14
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/setup.py +60 -9
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/vocab.py +2 -2
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely.egg-info/PKG-INFO +26 -9
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely.egg-info/SOURCES.txt +2 -2
- {chartremotely-0.2.7 → chartremotely-0.2.9}/pyproject.toml +1 -1
- chartremotely-0.2.9/tests/test_patron.py +275 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/tests/test_push.py +27 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/tests/test_relay.py +5 -4
- {chartremotely-0.2.7 → chartremotely-0.2.9}/tests/test_setup.py +62 -6
- chartremotely-0.2.7/chartremotely/forward.py +0 -79
- chartremotely-0.2.7/tests/test_forward.py +0 -203
- {chartremotely-0.2.7 → chartremotely-0.2.9}/LICENSE +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/__init__.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/ax.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/cli.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/doctor.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/guilock.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/layout.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/mcpclient.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/registry.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/resolve.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/scales.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/services.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/shortcut.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/snapshot.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/studies.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/symbol.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/tailnet.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/timeframe.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/window.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely.egg-info/dependency_links.txt +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely.egg-info/entry_points.txt +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely.egg-info/requires.txt +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely.egg-info/top_level.txt +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/setup.cfg +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/tests/test_requestlog.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/tests/test_resolve.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/tests/test_snapshot.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/tests/test_stamp_changelog.py +0 -0
- {chartremotely-0.2.7 → chartremotely-0.2.9}/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.9
|
|
4
4
|
Summary: Remote control of desktop charting applications on macOS
|
|
5
5
|
Author: Lonnie VanZandt
|
|
6
6
|
License:
|
|
@@ -321,16 +321,33 @@ ignores everything sent to it.
|
|
|
321
321
|
2. **What scale?** — a time frame mnemonic (below), or "as is".
|
|
322
322
|
3. **Where?** — which display. Leave it blank to stay on this Mac.
|
|
323
323
|
|
|
324
|
-
The Shortcut talks to one Mac — the one that made it.
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
324
|
+
The Shortcut talks to one Mac — the one that made it. That Mac looks up the
|
|
325
|
+
company and the scale itself (free: a table, never the chart), then acts as
|
|
326
|
+
an ordinary patron of the operator: it calls the priced `chart_show_chart`
|
|
327
|
+
tool with your npub, your sign-in token and the display named at "Where?"
|
|
328
|
+
(this Mac when blank). A voice change costs what `chart_show_chart` costs.
|
|
329
|
+
Siri answers within about three seconds — "Chart PLTR at half scale sent to
|
|
330
|
+
Mac-mini. Good luck." — and the chart changes a moment later through that
|
|
331
|
+
display's relay, which also takes its picture. Names may be loose: for a
|
|
332
|
+
display paired as "Mac mini", "mac-mini", "mini mac", "mini", "macm" and even
|
|
333
|
+
"mack meeny" all work. A refusal that comes back quickly — a balance too low,
|
|
334
|
+
a display that is unknown or not answering — is spoken instead, in the
|
|
335
|
+
operator's words. "Read" calls `chart_read_chart` and speaks what the chart
|
|
336
|
+
shows.
|
|
337
|
+
|
|
338
|
+
**Signing in.** The first `chartremotely setup` proves your npub by a Nostr
|
|
339
|
+
DM: reply from your Nostr client, and choose a long `cache_duration` in the
|
|
340
|
+
reply (e.g. `30 days`, or `unlimited`) so voice keeps working — left alone it
|
|
341
|
+
lasts 2 hours. The token the operator returns is kept in this Mac's login
|
|
342
|
+
Keychain ("ChartRemotely voice sign-in"), never in the config or a log; your
|
|
343
|
+
nsec is never asked for. When it expires you hear "Your ChartRemotely sign-in
|
|
344
|
+
expired. Answer the DM on your phone to renew." — a fresh DM is already on its
|
|
345
|
+
way, and once you answer it (within about half an hour) the next command
|
|
346
|
+
works again.
|
|
331
347
|
|
|
332
348
|
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 —
|
|
349
|
+
the "Where?" as said, and the reply if it was an ERR — plus one line when its
|
|
350
|
+
tool call ends (the tool, "Where?" and any refusal) — to
|
|
334
351
|
`~/Library/Logs/chartremotely-serve.log` (voice) or `chartremotely-relay.log`
|
|
335
352
|
(from the operator). Never a token, a secret or a picture.
|
|
336
353
|
|
|
@@ -97,16 +97,33 @@ ignores everything sent to it.
|
|
|
97
97
|
2. **What scale?** — a time frame mnemonic (below), or "as is".
|
|
98
98
|
3. **Where?** — which display. Leave it blank to stay on this Mac.
|
|
99
99
|
|
|
100
|
-
The Shortcut talks to one Mac — the one that made it.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
100
|
+
The Shortcut talks to one Mac — the one that made it. That Mac looks up the
|
|
101
|
+
company and the scale itself (free: a table, never the chart), then acts as
|
|
102
|
+
an ordinary patron of the operator: it calls the priced `chart_show_chart`
|
|
103
|
+
tool with your npub, your sign-in token and the display named at "Where?"
|
|
104
|
+
(this Mac when blank). A voice change costs what `chart_show_chart` costs.
|
|
105
|
+
Siri answers within about three seconds — "Chart PLTR at half scale sent to
|
|
106
|
+
Mac-mini. Good luck." — and the chart changes a moment later through that
|
|
107
|
+
display's relay, which also takes its picture. Names may be loose: for a
|
|
108
|
+
display paired as "Mac mini", "mac-mini", "mini mac", "mini", "macm" and even
|
|
109
|
+
"mack meeny" all work. A refusal that comes back quickly — a balance too low,
|
|
110
|
+
a display that is unknown or not answering — is spoken instead, in the
|
|
111
|
+
operator's words. "Read" calls `chart_read_chart` and speaks what the chart
|
|
112
|
+
shows.
|
|
113
|
+
|
|
114
|
+
**Signing in.** The first `chartremotely setup` proves your npub by a Nostr
|
|
115
|
+
DM: reply from your Nostr client, and choose a long `cache_duration` in the
|
|
116
|
+
reply (e.g. `30 days`, or `unlimited`) so voice keeps working — left alone it
|
|
117
|
+
lasts 2 hours. The token the operator returns is kept in this Mac's login
|
|
118
|
+
Keychain ("ChartRemotely voice sign-in"), never in the config or a log; your
|
|
119
|
+
nsec is never asked for. When it expires you hear "Your ChartRemotely sign-in
|
|
120
|
+
expired. Answer the DM on your phone to renew." — a fresh DM is already on its
|
|
121
|
+
way, and once you answer it (within about half an hour) the next command
|
|
122
|
+
works again.
|
|
107
123
|
|
|
108
124
|
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 —
|
|
125
|
+
the "Where?" as said, and the reply if it was an ERR — plus one line when its
|
|
126
|
+
tool call ends (the tool, "Where?" and any refusal) — to
|
|
110
127
|
`~/Library/Logs/chartremotely-serve.log` (voice) or `chartremotely-relay.log`
|
|
111
128
|
(from the operator). Never a token, a secret or a picture.
|
|
112
129
|
|
|
@@ -10,12 +10,14 @@ from typing import NamedTuple
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class Answer(NamedTuple):
|
|
13
|
-
"""A command's reply, plus the symbol it put on the chart when it did.
|
|
13
|
+
"""A command's reply, plus the symbol and scale it put on the chart when it did.
|
|
14
14
|
|
|
15
15
|
``symbol`` is set only by a successful chart change that named one; the
|
|
16
16
|
picture of that change is labelled with it, so a label never depends on
|
|
17
|
-
how the reply happens to be worded.
|
|
17
|
+
how the reply happens to be worded. ``scale`` is the time frame the reply
|
|
18
|
+
states ("half", "30 minutes"), or None when the change could not tell.
|
|
18
19
|
"""
|
|
19
20
|
|
|
20
21
|
reply: str
|
|
21
22
|
symbol: str | None = None
|
|
23
|
+
scale: str | None = None
|
|
@@ -1066,17 +1066,6 @@
|
|
|
1066
1066
|
<false/>
|
|
1067
1067
|
</dict>
|
|
1068
1068
|
</dict>
|
|
1069
|
-
<dict>
|
|
1070
|
-
<key>WFWorkflowActionIdentifier</key>
|
|
1071
|
-
<string>is.workflow.actions.speaktext</string>
|
|
1072
|
-
<key>WFWorkflowActionParameters</key>
|
|
1073
|
-
<dict>
|
|
1074
|
-
<key>WFSpeakTextWait</key>
|
|
1075
|
-
<false/>
|
|
1076
|
-
<key>WFText</key>
|
|
1077
|
-
<string>On it, requesting your chart now. Look at your screen or visit the website for the screen capture.</string>
|
|
1078
|
-
</dict>
|
|
1079
|
-
</dict>
|
|
1080
1069
|
<dict>
|
|
1081
1070
|
<key>WFWorkflowActionIdentifier</key>
|
|
1082
1071
|
<string>is.workflow.actions.downloadurl</string>
|
|
@@ -34,6 +34,9 @@ DEFAULTS: dict = {
|
|
|
34
34
|
"operator_url": None,
|
|
35
35
|
"agent_id": None,
|
|
36
36
|
"agent_secret": None,
|
|
37
|
+
# Whose display this is: the npub voice commands are billed to. Its
|
|
38
|
+
# sign-in (the dpop_token) lives in the login Keychain, never here.
|
|
39
|
+
"owner_npub": None,
|
|
37
40
|
"token": None,
|
|
38
41
|
"port": 8899,
|
|
39
42
|
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
"""Keep a patron's
|
|
1
|
+
"""Keep a patron's secrets in their login Keychain, never in this agent's files.
|
|
2
|
+
|
|
3
|
+
Two kinds, never mixed: the Nostr key ``setup`` may make for the human, and
|
|
4
|
+
the voice sign-in - the ``dpop_token`` the operator returns once the owner
|
|
5
|
+
has proven their npub by DM - which the listener passes on each priced call.
|
|
2
6
|
|
|
3
7
|
A key made by ``setup`` belongs to the human. It goes into their login
|
|
4
8
|
Keychain as an internet password for the ChartRemotely site, and it is
|
|
@@ -8,8 +12,8 @@ tool cannot write a synchronizable Keychain item itself (that needs an
|
|
|
8
12
|
entitled app: ``errSecMissingEntitlement``), which is why Safari does the
|
|
9
13
|
syncing.
|
|
10
14
|
|
|
11
|
-
Nothing here writes
|
|
12
|
-
command line.
|
|
15
|
+
Nothing here writes a key or a token to the agent's config, a log, a URL or
|
|
16
|
+
a command line. Each travels as ``kSecValueData`` or on a pipe, and nowhere else.
|
|
13
17
|
"""
|
|
14
18
|
|
|
15
19
|
from __future__ import annotations
|
|
@@ -19,6 +23,8 @@ import threading
|
|
|
19
23
|
|
|
20
24
|
SERVER = "chartremotely.tollbooth-dpyc.com"
|
|
21
25
|
LABEL = "ChartRemotely — Nostr key"
|
|
26
|
+
#: The voice sign-in is a generic password under this service, one per npub.
|
|
27
|
+
TOKEN_SERVICE = "ChartRemotely voice sign-in"
|
|
22
28
|
CLIPBOARD_SECONDS = 60
|
|
23
29
|
|
|
24
30
|
ERR_DUPLICATE = -25299
|
|
@@ -43,15 +49,43 @@ def item_query(npub: str) -> dict:
|
|
|
43
49
|
S.kSecAttrAccount: npub}
|
|
44
50
|
|
|
45
51
|
|
|
52
|
+
def _upsert(query: dict, label: str, secret: bytes, what: str) -> None:
|
|
53
|
+
"""Add one item, or replace the secret of the one already there."""
|
|
54
|
+
S = _security()
|
|
55
|
+
status = S.SecItemAdd({**query, S.kSecAttrLabel: label, S.kSecValueData: secret}, None)[0]
|
|
56
|
+
if status == ERR_DUPLICATE:
|
|
57
|
+
status = S.SecItemUpdate(query, {S.kSecValueData: secret})
|
|
58
|
+
if status != 0:
|
|
59
|
+
raise KeystoreError(f"the Keychain refused to save the {what} (status {status})")
|
|
60
|
+
|
|
61
|
+
|
|
46
62
|
def save(npub: str, nsec: str) -> None:
|
|
47
63
|
"""Store (or replace) the patron's key in their login Keychain."""
|
|
64
|
+
_upsert(item_query(npub), LABEL, nsec.encode(), "key")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def token_query(npub: str) -> dict:
|
|
68
|
+
"""The attributes that name one npub's voice sign-in. The token is never among them."""
|
|
48
69
|
S = _security()
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
70
|
+
return {S.kSecClass: S.kSecClassGenericPassword,
|
|
71
|
+
S.kSecAttrService: TOKEN_SERVICE,
|
|
72
|
+
S.kSecAttrAccount: npub}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def save_token(npub: str, token: str) -> None:
|
|
76
|
+
"""Store (or replace) the voice sign-in for ``npub``."""
|
|
77
|
+
_upsert(token_query(npub), TOKEN_SERVICE, token.encode(), "sign-in")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_token(npub: str) -> str | None:
|
|
81
|
+
"""The voice sign-in for ``npub``; None when there is none yet."""
|
|
82
|
+
S = _security()
|
|
83
|
+
status, data = S.SecItemCopyMatching({**token_query(npub), S.kSecReturnData: True}, None)
|
|
84
|
+
if status == ERR_NOT_FOUND or (status == 0 and data is None):
|
|
85
|
+
return None
|
|
53
86
|
if status != 0:
|
|
54
|
-
raise KeystoreError(f"the Keychain
|
|
87
|
+
raise KeystoreError(f"the Keychain would not give the sign-in (status {status})")
|
|
88
|
+
return bytes(data).decode()
|
|
55
89
|
|
|
56
90
|
|
|
57
91
|
def saved_npubs() -> list[str]:
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""The listener as an ordinary patron: a voice command becomes a priced tool call.
|
|
2
|
+
|
|
3
|
+
The Mac plays by the operator's rules like any other MCP client. A chart
|
|
4
|
+
change heard by voice is a call to the operator's own ``chart_show_chart``
|
|
5
|
+
(and "read" to ``chart_read_chart``), made with the display owner's ``npub``
|
|
6
|
+
and the ``dpop_token`` they earned by answering a Nostr DM - the same
|
|
7
|
+
envelope every patron sends. The operator prices it, relays it to the named
|
|
8
|
+
display (blank "Where?": this Mac, by its agent_id), and that display's relay
|
|
9
|
+
changes the chart and takes its picture. Nothing here drives the chart.
|
|
10
|
+
|
|
11
|
+
Siri should not wait for the chart. The call starts in the background; a
|
|
12
|
+
refusal that comes back within :data:`HANDOFF_SECONDS` (sign-in expired, not
|
|
13
|
+
enough balance, no such display) is spoken, and otherwise the hand-off is
|
|
14
|
+
spoken at once - "Chart PLTR at half scale sent to Mac-mini. Good luck." -
|
|
15
|
+
while the call finishes on its own and its outcome is logged.
|
|
16
|
+
|
|
17
|
+
When the sign-in has expired the owner hears so, and one renewal runs in the
|
|
18
|
+
background: a fresh ``chart_request_npub_proof`` DM, then
|
|
19
|
+
``chart_receive_npub_proof`` on a bounded, slowing schedule until they answer,
|
|
20
|
+
and the new token goes back into the Keychain. The nsec is never asked for.
|
|
21
|
+
|
|
22
|
+
Stdlib only (the Keychain behind a lazy import), like the relay.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
|
|
31
|
+
from . import config, keystore, mcpclient, requestlog
|
|
32
|
+
|
|
33
|
+
ERR = "ERR "
|
|
34
|
+
#: How long a spoken reply waits for an early refusal before handing off.
|
|
35
|
+
HANDOFF_SECONDS = 3.0
|
|
36
|
+
#: "read" is answered with what the chart shows, so it may wait longer.
|
|
37
|
+
READ_SECONDS = 25.0
|
|
38
|
+
#: Bounds a cold operator plus the relay's own wait; the reply never waits on it.
|
|
39
|
+
CALL_TIMEOUT = 60.0
|
|
40
|
+
AS_IS = {"", "as is", "asis"}
|
|
41
|
+
|
|
42
|
+
#: What the SDK answers when a paid call's dpop_token is missing, unknown or expired.
|
|
43
|
+
PROOF_CODES = frozenset({"proof_missing", "proof_required", "proof_refresh_needed", "proof_invalid"})
|
|
44
|
+
EXPIRED = ERR + "Your ChartRemotely sign-in expired. Answer the DM on your phone to renew."
|
|
45
|
+
REASON = "Your Mac's ChartRemotely voice control needs you to sign in again."
|
|
46
|
+
#: Seconds between looks for the owner's reply: quick at first, then patient;
|
|
47
|
+
#: about half an hour in all, then the renewal gives up until the next command.
|
|
48
|
+
RENEW_DELAYS = (15.0,) * 4 + (30.0,) * 6 + (60.0,) * 25
|
|
49
|
+
#: The one receive answer that means "not yet" rather than "stop".
|
|
50
|
+
NOT_YET = "courier_not_found"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# -- what a spoken command asks of the operator ----------------------------------
|
|
54
|
+
|
|
55
|
+
def tool_for(command: str) -> tuple[str, dict] | str:
|
|
56
|
+
"""The priced tool and its arguments for one command, or an ERR sentence.
|
|
57
|
+
|
|
58
|
+
``set TICKER | scale`` -> chart_show_chart; ``read`` -> chart_read_chart;
|
|
59
|
+
a bare name -> chart_show_chart with that name. ``snapshot`` has a
|
|
60
|
+
priced tool, but a picture cannot be spoken: it is refused here.
|
|
61
|
+
"""
|
|
62
|
+
verb, _, rest = command.partition(" ")
|
|
63
|
+
verb = verb.lower()
|
|
64
|
+
if verb == "set":
|
|
65
|
+
ticker, _, scale = rest.partition("|")
|
|
66
|
+
scale = scale.strip()
|
|
67
|
+
return "chart_show_chart", {"security": ticker.strip(),
|
|
68
|
+
"scale": "" if scale.lower() in AS_IS else scale}
|
|
69
|
+
if verb == "read":
|
|
70
|
+
return "chart_read_chart", {}
|
|
71
|
+
if verb == "snapshot":
|
|
72
|
+
return ERR + "A picture cannot be spoken. See your screens at chartremotely.tollbooth-dpyc.com."
|
|
73
|
+
return "chart_show_chart", {"security": command, "scale": ""}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def handoff(args: dict, where: str) -> str:
|
|
77
|
+
"""What to say once a chart change is on its way: what, and where to."""
|
|
78
|
+
at = f" at {args['scale']} scale" if args.get("scale") else ""
|
|
79
|
+
return f"Chart {args['security']}{at} sent to {where or 'this Mac'}. Good luck."
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def refusal(payload: dict) -> str | None:
|
|
83
|
+
"""The operator's reason for refusing, when it refused; else None."""
|
|
84
|
+
if payload.get("success") is False or payload.get("ok") is False or payload.get("error"):
|
|
85
|
+
return str(payload.get("error") or payload.get("result") or "the operator refused")
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# -- the call -----------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def _call(base: str, tool: str, args: dict) -> dict:
|
|
92
|
+
"""One tool call. Never raises: an unreachable operator is an ``error`` payload."""
|
|
93
|
+
try:
|
|
94
|
+
return mcpclient.Client(base, timeout=CALL_TIMEOUT).call(tool, args)
|
|
95
|
+
except mcpclient.McpError as exc:
|
|
96
|
+
return {"success": False, "error": str(exc)}
|
|
97
|
+
except Exception as exc: # noqa: BLE001 - a background call must never kill the listener
|
|
98
|
+
return {"success": False, "error": f"the call failed: {type(exc).__name__}"}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def send(command: str, where: str, cfg: dict | None = None) -> str:
|
|
102
|
+
"""Ask the operator's priced tool for one command, for the display named ``where``.
|
|
103
|
+
|
|
104
|
+
Returns the sentence to speak. Never raises.
|
|
105
|
+
"""
|
|
106
|
+
cfg = cfg or config.load()
|
|
107
|
+
base = (cfg.get("operator_url") or "").rstrip("/")
|
|
108
|
+
agent_id, npub = cfg.get("agent_id"), cfg.get("owner_npub")
|
|
109
|
+
if not (base and agent_id):
|
|
110
|
+
return ERR + "This Mac is not paired, so it cannot change a chart. Run chartremotely setup."
|
|
111
|
+
if not npub:
|
|
112
|
+
return ERR + "This Mac does not know whose display it is. Run chartremotely setup."
|
|
113
|
+
# The Shortcut splices this Mac's own replies ("PLTR\n") into the command.
|
|
114
|
+
wanted = " ".join((where or "").split())
|
|
115
|
+
command = " ".join((command or "").split())
|
|
116
|
+
mapped = tool_for(command)
|
|
117
|
+
if isinstance(mapped, str):
|
|
118
|
+
return mapped
|
|
119
|
+
tool, args = mapped
|
|
120
|
+
try:
|
|
121
|
+
token = keystore.load_token(npub)
|
|
122
|
+
except keystore.KeystoreError as exc:
|
|
123
|
+
requestlog.outcome("serve", tool, wanted, str(exc))
|
|
124
|
+
return ERR + "The Keychain would not give me your ChartRemotely sign-in."
|
|
125
|
+
if not token:
|
|
126
|
+
renew(base, npub)
|
|
127
|
+
return EXPIRED
|
|
128
|
+
|
|
129
|
+
envelope = {**args, "display": wanted or agent_id, "npub": npub, "dpop_token": token}
|
|
130
|
+
box: dict = {}
|
|
131
|
+
done = threading.Event()
|
|
132
|
+
|
|
133
|
+
def work() -> None:
|
|
134
|
+
payload = _call(base, tool, envelope)
|
|
135
|
+
box["payload"] = payload
|
|
136
|
+
settle(base, npub, tool, wanted, payload)
|
|
137
|
+
done.set()
|
|
138
|
+
|
|
139
|
+
threading.Thread(target=work, name=f"call-{tool}", daemon=True).start()
|
|
140
|
+
if not done.wait(READ_SECONDS if tool == "chart_read_chart" else HANDOFF_SECONDS):
|
|
141
|
+
return handoff(args, wanted) if tool == "chart_show_chart" else \
|
|
142
|
+
f"Reading {wanted or 'this Mac'}; the answer is taking a while."
|
|
143
|
+
payload = box["payload"]
|
|
144
|
+
if payload.get("error_code") in PROOF_CODES:
|
|
145
|
+
return EXPIRED
|
|
146
|
+
if (why := refusal(payload)) is not None:
|
|
147
|
+
return ERR + why.removeprefix(ERR)
|
|
148
|
+
if tool == "chart_read_chart":
|
|
149
|
+
return str(payload.get("result") or "")
|
|
150
|
+
return handoff(args, wanted)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def settle(base: str, npub: str, tool: str, where: str, payload: dict) -> None:
|
|
154
|
+
"""Log how a call ended (no token, no arguments) and renew an expired sign-in."""
|
|
155
|
+
if payload.get("error_code") in PROOF_CODES:
|
|
156
|
+
renew(base, npub)
|
|
157
|
+
requestlog.outcome("serve", tool, where, refusal(payload))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# -- renewing an expired sign-in ------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
_renewing = threading.Lock()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def renew(base: str, npub: str, sleep: Callable[[float], None] = time.sleep) -> bool:
|
|
166
|
+
"""Start one background renewal; False when one is already under way."""
|
|
167
|
+
if not _renewing.acquire(blocking=False):
|
|
168
|
+
return False
|
|
169
|
+
threading.Thread(target=_renew, args=(base, npub, sleep), name="renew-sign-in", daemon=True).start()
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _renew(base: str, npub: str, sleep: Callable[[float], None]) -> None:
|
|
174
|
+
try:
|
|
175
|
+
sent = _call(base, "chart_request_npub_proof", {"patron_npub": npub, "reason": REASON})
|
|
176
|
+
phrase = sent.get("dpop_token")
|
|
177
|
+
if not phrase:
|
|
178
|
+
requestlog.outcome("serve", "renew", "", refusal(sent) or "no proof request was sent")
|
|
179
|
+
return
|
|
180
|
+
for delay in RENEW_DELAYS:
|
|
181
|
+
sleep(delay)
|
|
182
|
+
got = _call(base, "chart_receive_npub_proof", {"patron_npub": npub, "dpop_token": phrase})
|
|
183
|
+
if got.get("success") is not False and not got.get("error"):
|
|
184
|
+
keystore.save_token(npub, str(got.get("dpop_token") or phrase))
|
|
185
|
+
requestlog.outcome("serve", "renew", "", None)
|
|
186
|
+
return
|
|
187
|
+
if got.get("error_code") not in (NOT_YET, None):
|
|
188
|
+
requestlog.outcome("serve", "renew", "", refusal(got))
|
|
189
|
+
return
|
|
190
|
+
requestlog.outcome("serve", "renew", "", "the DM was not answered in time")
|
|
191
|
+
except Exception as exc: # noqa: BLE001 - renewal must never take the listener down
|
|
192
|
+
requestlog.outcome("serve", "renew", "", f"renewal failed: {type(exc).__name__}")
|
|
193
|
+
finally:
|
|
194
|
+
_renewing.release()
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
A chart changed by voice goes straight to this machine over the tailnet, so
|
|
4
4
|
the operator never hears of it. Once a change is answered — after the reply
|
|
5
5
|
("… Good luck.") has been sent — one picture of the chart pane is pushed up so
|
|
6
|
-
the patron's browser can offer it, labelled with the symbol
|
|
7
|
-
of the burst put on the chart (the symbol field is read only as
|
|
8
|
-
it can be stale). The operator keeps the newest picture per symbol,
|
|
6
|
+
the patron's browser can offer it, labelled with the symbol and scale the
|
|
7
|
+
last command of the burst put on the chart (the symbol field is read only as
|
|
8
|
+
a fallback: it can be stale; the scale is never read back). The operator keeps the newest picture per symbol,
|
|
9
9
|
encrypted, for an hour each.
|
|
10
10
|
|
|
11
11
|
Three rules keep it out of the way:
|
|
@@ -44,10 +44,16 @@ _NOT_A_CHANGE = frozenset({"resolve", "scale", "read", "snapshot"})
|
|
|
44
44
|
#: the same shape; anything else is left off rather than sent.
|
|
45
45
|
SYMBOL = re.compile(r"^[A-Z0-9./^$-]{1,15}$")
|
|
46
46
|
|
|
47
|
+
#: What a scale label may look like on the wire: the words a reply states
|
|
48
|
+
#: ("half", "30 minutes", "1 day"). The operator checks the same shape.
|
|
49
|
+
SCALE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 .:/-]{0,23}$")
|
|
50
|
+
|
|
47
51
|
_state = threading.Condition()
|
|
48
52
|
#: The symbol the last successful chart-changing command put on the chart.
|
|
49
53
|
#: A burst's picture carries the LAST one; a change that names none keeps it.
|
|
50
54
|
_named: str | None = None
|
|
55
|
+
#: The scale the last chart-changing command stated; kept the same way.
|
|
56
|
+
_scaled: str | None = None
|
|
51
57
|
_due: float | None = None
|
|
52
58
|
_worker: threading.Thread | None = None
|
|
53
59
|
|
|
@@ -66,12 +72,22 @@ def symbol_label(raw: object) -> str | None:
|
|
|
66
72
|
return label if SYMBOL.match(label) else None
|
|
67
73
|
|
|
68
74
|
|
|
69
|
-
def
|
|
75
|
+
def scale_label(raw: object) -> str | None:
|
|
76
|
+
"""The chart's scale as sent to the operator, or None when it is not scale-shaped."""
|
|
77
|
+
if not isinstance(raw, str):
|
|
78
|
+
return None
|
|
79
|
+
label = " ".join(raw.split())
|
|
80
|
+
return label if SCALE.match(label) else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def payload(cfg: dict, image: str, symbol: str | None = None,
|
|
84
|
+
scale: str | None = None) -> tuple[str, dict] | None:
|
|
70
85
|
"""Where to send a picture and what to send, or None when not paired.
|
|
71
86
|
|
|
72
87
|
``symbol`` is what the chart showed when the picture was taken; it is left
|
|
73
88
|
out when unknown or not symbol-shaped, and the operator files the picture
|
|
74
|
-
under a plain "Chart".
|
|
89
|
+
under a plain "Chart". ``scale`` is the time frame the last change stated;
|
|
90
|
+
it is left out the same way.
|
|
75
91
|
"""
|
|
76
92
|
base = (cfg.get("operator_url") or "").rstrip("/")
|
|
77
93
|
agent_id, secret = cfg.get("agent_id"), cfg.get("agent_secret")
|
|
@@ -81,6 +97,9 @@ def payload(cfg: dict, image: str, symbol: str | None = None) -> tuple[str, dict
|
|
|
81
97
|
label = symbol_label(symbol)
|
|
82
98
|
if label:
|
|
83
99
|
body["symbol"] = label
|
|
100
|
+
scaled = scale_label(scale)
|
|
101
|
+
if scaled:
|
|
102
|
+
body["scale"] = scaled
|
|
84
103
|
return f"{base}/agent/snapshot", body
|
|
85
104
|
|
|
86
105
|
|
|
@@ -98,16 +117,19 @@ def _showing() -> str | None:
|
|
|
98
117
|
return None
|
|
99
118
|
|
|
100
119
|
|
|
101
|
-
def after_reply(request: str, reply: str, symbol: str | None = None
|
|
120
|
+
def after_reply(request: str, reply: str, symbol: str | None = None,
|
|
121
|
+
scale: str | None = None) -> None:
|
|
102
122
|
"""Call once a reply has been sent. Schedules a picture if the chart changed.
|
|
103
123
|
|
|
104
|
-
``symbol``
|
|
105
|
-
it named
|
|
124
|
+
``symbol`` and ``scale`` are what the command put on the chart
|
|
125
|
+
(vocab.Answer), when it named them; a change that names neither keeps the
|
|
126
|
+
previous command's.
|
|
106
127
|
"""
|
|
107
|
-
global _named
|
|
128
|
+
global _named, _scaled
|
|
108
129
|
if changes_chart(request, reply):
|
|
109
130
|
with _state:
|
|
110
131
|
_named = symbol_label(symbol) or _named
|
|
132
|
+
_scaled = scale_label(scale) or _scaled
|
|
111
133
|
schedule()
|
|
112
134
|
|
|
113
135
|
|
|
@@ -144,9 +166,9 @@ def push_now() -> None:
|
|
|
144
166
|
with guilock.driving():
|
|
145
167
|
image = snapshot.as_reply(snapshot.shrink(window.capture()))
|
|
146
168
|
with _state:
|
|
147
|
-
label = _named
|
|
169
|
+
label, scaled = _named, _scaled
|
|
148
170
|
label = label or _showing()
|
|
149
|
-
url, body = payload(cfg, image, label)
|
|
171
|
+
url, body = payload(cfg, image, label, scaled)
|
|
150
172
|
relay._post(url, body, timeout=30)
|
|
151
173
|
except Exception: # noqa: BLE001, S110 - deliberate: see the module docstring
|
|
152
174
|
pass
|
|
@@ -147,6 +147,6 @@ def run(operator_url: str | None = None, once: bool = False) -> None:
|
|
|
147
147
|
requestlog.request("relay", command, "", reply)
|
|
148
148
|
# After the reply, never before: the picture of a changed chart.
|
|
149
149
|
from . import push
|
|
150
|
-
push.after_reply(command, reply, result.symbol)
|
|
150
|
+
push.after_reply(command, reply, result.symbol, result.scale)
|
|
151
151
|
if once:
|
|
152
152
|
return
|
|
@@ -39,3 +39,15 @@ def request(source: str, command: str, where: str, reply: str) -> None:
|
|
|
39
39
|
print(line(source, command, where, reply), file=sys.stderr, flush=True)
|
|
40
40
|
except (OSError, ValueError):
|
|
41
41
|
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def outcome(source: str, tool: str, where: str, error: str | None) -> None:
|
|
45
|
+
"""One line for how a background tool call ended: the tool, the "Where?",
|
|
46
|
+
and the refusal when there was one. Never its arguments or the sign-in."""
|
|
47
|
+
parts = [time.strftime("%Y-%m-%dT%H:%M:%S%z"), source, f"tool={_clean(tool or '-', 48)}",
|
|
48
|
+
f"where={_clean(where or '', WHERE_MAX)!r}",
|
|
49
|
+
f"error={_clean(error, ERR_MAX)!r}" if error else "ok"]
|
|
50
|
+
try:
|
|
51
|
+
print(" ".join(parts), file=sys.stderr, flush=True)
|
|
52
|
+
except (OSError, ValueError):
|
|
53
|
+
pass
|
|
@@ -17,9 +17,12 @@ import secrets
|
|
|
17
17
|
import urllib.parse
|
|
18
18
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
19
19
|
|
|
20
|
-
from . import config,
|
|
20
|
+
from . import config, patron, requestlog
|
|
21
21
|
from .vocab import answer
|
|
22
22
|
|
|
23
|
+
#: Verbs answered here, for free: they look something up and leave the chart alone.
|
|
24
|
+
LOOKUPS = frozenset({"resolve", "scale"})
|
|
25
|
+
|
|
23
26
|
|
|
24
27
|
class Handler(BaseHTTPRequestHandler):
|
|
25
28
|
token = ""
|
|
@@ -64,20 +67,23 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
64
67
|
(query.get("where") or [""])[0].strip())
|
|
65
68
|
|
|
66
69
|
def _answer(self, request: str, where: str = "") -> None:
|
|
67
|
-
"""
|
|
68
|
-
|
|
69
|
-
``
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
"""Answer a lookup here; ask the operator's priced tool for the rest.
|
|
71
|
+
|
|
72
|
+
``resolve`` and ``scale`` are the Shortcut's free checks before it
|
|
73
|
+
asks for a change: they read a table, never the chart. Everything
|
|
74
|
+
else is a patron's tool call (see :mod:`patron`) - ``where`` names the
|
|
75
|
+
display as dictated, blank meaning this Mac. The reply is the
|
|
76
|
+
hand-off, spoken at once; the chart changes after, through a relay,
|
|
77
|
+
which also takes its picture.
|
|
72
78
|
"""
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
if request.partition(" ")[0].lower() in LOOKUPS:
|
|
80
|
+
reply = answer(request).reply
|
|
81
|
+
elif request:
|
|
82
|
+
reply = patron.send(request, where)
|
|
83
|
+
else:
|
|
84
|
+
reply = "ERR I didn't catch that."
|
|
85
|
+
self._reply(200, reply)
|
|
86
|
+
requestlog.request("serve", request, where, reply)
|
|
81
87
|
|
|
82
88
|
def log_message(self, *args) -> None:
|
|
83
89
|
"""Silence the stock access log: its request line can carry ``?t=<token>``.
|