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.
Files changed (51) hide show
  1. {chartremotely-0.2.7 → chartremotely-0.2.9}/PKG-INFO +26 -9
  2. {chartremotely-0.2.7 → chartremotely-0.2.9}/README.md +25 -8
  3. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/answer.py +4 -2
  4. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/assets/shortcut-template.plist +0 -11
  5. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/config.py +3 -0
  6. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/keystore.py +42 -8
  7. chartremotely-0.2.9/chartremotely/patron.py +194 -0
  8. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/push.py +33 -11
  9. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/relay.py +1 -1
  10. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/requestlog.py +12 -0
  11. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/server.py +20 -14
  12. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/setup.py +60 -9
  13. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/vocab.py +2 -2
  14. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely.egg-info/PKG-INFO +26 -9
  15. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely.egg-info/SOURCES.txt +2 -2
  16. {chartremotely-0.2.7 → chartremotely-0.2.9}/pyproject.toml +1 -1
  17. chartremotely-0.2.9/tests/test_patron.py +275 -0
  18. {chartremotely-0.2.7 → chartremotely-0.2.9}/tests/test_push.py +27 -0
  19. {chartremotely-0.2.7 → chartremotely-0.2.9}/tests/test_relay.py +5 -4
  20. {chartremotely-0.2.7 → chartremotely-0.2.9}/tests/test_setup.py +62 -6
  21. chartremotely-0.2.7/chartremotely/forward.py +0 -79
  22. chartremotely-0.2.7/tests/test_forward.py +0 -203
  23. {chartremotely-0.2.7 → chartremotely-0.2.9}/LICENSE +0 -0
  24. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/__init__.py +0 -0
  25. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/ax.py +0 -0
  26. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/cli.py +0 -0
  27. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/doctor.py +0 -0
  28. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/guilock.py +0 -0
  29. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/layout.py +0 -0
  30. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/mcpclient.py +0 -0
  31. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/registry.py +0 -0
  32. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/resolve.py +0 -0
  33. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/scales.py +0 -0
  34. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/services.py +0 -0
  35. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/shortcut.py +0 -0
  36. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/snapshot.py +0 -0
  37. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/studies.py +0 -0
  38. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/symbol.py +0 -0
  39. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/tailnet.py +0 -0
  40. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/timeframe.py +0 -0
  41. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely/window.py +0 -0
  42. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely.egg-info/dependency_links.txt +0 -0
  43. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely.egg-info/entry_points.txt +0 -0
  44. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely.egg-info/requires.txt +0 -0
  45. {chartremotely-0.2.7 → chartremotely-0.2.9}/chartremotely.egg-info/top_level.txt +0 -0
  46. {chartremotely-0.2.7 → chartremotely-0.2.9}/setup.cfg +0 -0
  47. {chartremotely-0.2.7 → chartremotely-0.2.9}/tests/test_requestlog.py +0 -0
  48. {chartremotely-0.2.7 → chartremotely-0.2.9}/tests/test_resolve.py +0 -0
  49. {chartremotely-0.2.7 → chartremotely-0.2.9}/tests/test_snapshot.py +0 -0
  50. {chartremotely-0.2.7 → chartremotely-0.2.9}/tests/test_stamp_changelog.py +0 -0
  51. {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.7
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. 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.
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 — to
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. 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.
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 — to
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 Nostr key for the patron, never for this agent.
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 the key to the agent's config, a log, a URL or a
12
- command line. It travels as ``kSecValueData`` or on a pipe, and nowhere else.
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
- secret = nsec.encode()
50
- status = S.SecItemAdd({**item_query(npub), S.kSecAttrLabel: LABEL, S.kSecValueData: secret}, None)[0]
51
- if status == ERR_DUPLICATE:
52
- status = S.SecItemUpdate(item_query(npub), {S.kSecValueData: secret})
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 refused to save the key (status {status})")
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 the last command
7
- of the burst put on the chart (the symbol field is read only as a fallback:
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 payload(cfg: dict, image: str, symbol: str | None = None) -> tuple[str, dict] | None:
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) -> 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`` is what the command put on the chart (vocab.Answer.symbol), when
105
- it named one; a change that names none keeps the previous command's.
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, forward, push, requestlog
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
- """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.
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
- 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)
77
- result = answer(request)
78
- self._reply(200, result.reply)
79
- requestlog.request("serve", request, where, result.reply)
80
- push.after_reply(request, result.reply, result.symbol)
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>``.