chartremotely 0.2.8__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.8 → chartremotely-0.2.9}/PKG-INFO +26 -9
  2. {chartremotely-0.2.8 → chartremotely-0.2.9}/README.md +25 -8
  3. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/assets/shortcut-template.plist +0 -11
  4. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/config.py +3 -0
  5. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/keystore.py +42 -8
  6. chartremotely-0.2.9/chartremotely/patron.py +194 -0
  7. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/requestlog.py +12 -0
  8. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/server.py +20 -14
  9. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/setup.py +60 -9
  10. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely.egg-info/PKG-INFO +26 -9
  11. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely.egg-info/SOURCES.txt +2 -2
  12. {chartremotely-0.2.8 → chartremotely-0.2.9}/pyproject.toml +1 -1
  13. chartremotely-0.2.9/tests/test_patron.py +275 -0
  14. {chartremotely-0.2.8 → chartremotely-0.2.9}/tests/test_relay.py +2 -1
  15. {chartremotely-0.2.8 → chartremotely-0.2.9}/tests/test_setup.py +62 -6
  16. chartremotely-0.2.8/chartremotely/forward.py +0 -79
  17. chartremotely-0.2.8/tests/test_forward.py +0 -203
  18. {chartremotely-0.2.8 → chartremotely-0.2.9}/LICENSE +0 -0
  19. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/__init__.py +0 -0
  20. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/answer.py +0 -0
  21. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/ax.py +0 -0
  22. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/cli.py +0 -0
  23. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/doctor.py +0 -0
  24. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/guilock.py +0 -0
  25. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/layout.py +0 -0
  26. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/mcpclient.py +0 -0
  27. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/push.py +0 -0
  28. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/registry.py +0 -0
  29. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/relay.py +0 -0
  30. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/resolve.py +0 -0
  31. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/scales.py +0 -0
  32. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/services.py +0 -0
  33. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/shortcut.py +0 -0
  34. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/snapshot.py +0 -0
  35. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/studies.py +0 -0
  36. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/symbol.py +0 -0
  37. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/tailnet.py +0 -0
  38. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/timeframe.py +0 -0
  39. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/vocab.py +0 -0
  40. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely/window.py +0 -0
  41. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely.egg-info/dependency_links.txt +0 -0
  42. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely.egg-info/entry_points.txt +0 -0
  43. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely.egg-info/requires.txt +0 -0
  44. {chartremotely-0.2.8 → chartremotely-0.2.9}/chartremotely.egg-info/top_level.txt +0 -0
  45. {chartremotely-0.2.8 → chartremotely-0.2.9}/setup.cfg +0 -0
  46. {chartremotely-0.2.8 → chartremotely-0.2.9}/tests/test_push.py +0 -0
  47. {chartremotely-0.2.8 → chartremotely-0.2.9}/tests/test_requestlog.py +0 -0
  48. {chartremotely-0.2.8 → chartremotely-0.2.9}/tests/test_resolve.py +0 -0
  49. {chartremotely-0.2.8 → chartremotely-0.2.9}/tests/test_snapshot.py +0 -0
  50. {chartremotely-0.2.8 → chartremotely-0.2.9}/tests/test_stamp_changelog.py +0 -0
  51. {chartremotely-0.2.8 → 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.8
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
 
@@ -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()
@@ -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, result.scale)
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>``.
@@ -9,6 +9,9 @@ again is always safe:
9
9
  passwords - and used once, in memory, to sign the pairing. It is never
10
10
  written to this agent's config.
11
11
  2. **Pairing**, without a code to copy: setup adopts this machine's code itself.
12
+ **Voice sign-in**: the ``dpop_token`` from the owner's DM proof, kept in
13
+ the login Keychain (their npub goes in the config), so the listener can
14
+ call the operator's priced tools as that patron.
12
15
  3. **Tailscale**: the Mac's tailnet address, with ``/chart`` forwarded to the agent.
13
16
  4. **Services**: the listener and the relay, under launchd.
14
17
  5. **Permissions**: Accessibility and Screen Recording, asked for by the
@@ -53,6 +56,8 @@ def prove_by_dm(client: mcpclient.Client, npub: str, ask: Ask, say: Say) -> str:
53
56
  say(f"A Nostr DM is on its way to {npub[:12]}…")
54
57
  say(f"Its confirmation code is: {phrase}")
55
58
  say("Reply to the DM from your Nostr client only if the codes match.")
59
+ say("In your reply you may set cache_duration - e.g. 30 days, or unlimited - so voice")
60
+ say("keeps working that long; left alone, the sign-in lasts 2 hours.")
56
61
  ask("Press Return once you have replied… ")
57
62
  got = client.call("chart_receive_npub_proof", {"patron_npub": npub, "dpop_token": phrase})
58
63
  if got.get("error"):
@@ -92,8 +97,12 @@ def keep_for_human(npub: str, nsec: str, ask: Ask, say: Say) -> None:
92
97
  keystore.clear_if_unchanged(nsec)
93
98
 
94
99
 
95
- def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str]:
96
- """Who owns this display, proven. Returns (npub, proof for chart_pair_agent)."""
100
+ def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str, bool]:
101
+ """Who owns this display, proven.
102
+
103
+ Returns (npub, proof for chart_pair_agent, whether that proof is a DM
104
+ sign-in the listener can keep using).
105
+ """
97
106
  say("Your Nostr identity owns this display.")
98
107
  say(" 1 I have an npub (I'll answer a Nostr DM to prove it)")
99
108
  say(" 2 Use a key saved on this Mac")
@@ -103,7 +112,7 @@ def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str]:
103
112
  npub = ask("Your npub: ").strip()
104
113
  if not npub.startswith("npub1"):
105
114
  raise SetupError("that is not an npub")
106
- return npub, prove_by_dm(client, npub, ask, say)
115
+ return npub, prove_by_dm(client, npub, ask, say), True
107
116
  if choice == "2":
108
117
  from . import keystore
109
118
 
@@ -115,7 +124,7 @@ def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str]:
115
124
  picked = saved[int(ask("Which key? ").strip() or "1") - 1]
116
125
  nsec = keystore.load(picked) # macOS asks the human first
117
126
  try:
118
- return picked, sign_once(nsec, "chart_pair_agent")
127
+ return picked, sign_once(nsec, "chart_pair_agent"), False
119
128
  finally:
120
129
  del nsec
121
130
  if choice == "3":
@@ -123,12 +132,42 @@ def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str]:
123
132
  try:
124
133
  say(f"Your new npub is {npub}")
125
134
  keep_for_human(npub, nsec, ask, say)
126
- return npub, sign_once(nsec, "chart_pair_agent")
135
+ return npub, sign_once(nsec, "chart_pair_agent"), False
127
136
  finally:
128
137
  del nsec
129
138
  raise SetupError("choose 1, 2 or 3")
130
139
 
131
140
 
141
+ # -- the voice sign-in --------------------------------------------------------
142
+
143
+ def keep_sign_in(npub: str, token: str) -> None:
144
+ """The owner's npub into the config; their sign-in into the Keychain, only."""
145
+ from . import keystore
146
+
147
+ keystore.save_token(npub, token)
148
+ config.update(owner_npub=npub)
149
+
150
+
151
+ def has_sign_in(npub: str) -> bool:
152
+ from . import keystore
153
+
154
+ try:
155
+ return bool(keystore.load_token(npub))
156
+ except keystore.KeystoreError:
157
+ return False
158
+
159
+
160
+ def voice_sign_in(client: mcpclient.Client, npub: str, ask: Ask, say: Say) -> None:
161
+ """Voice commands are priced tool calls made as the owner: prove the npub by DM for them."""
162
+ config.update(owner_npub=npub)
163
+ say(f"Voice commands are paid for by {npub[:12]}…, signed in by a Nostr DM.")
164
+ if ask("Sign in for voice now? [Y/n] ").strip().lower().startswith("n"):
165
+ say("Then your first voice command sends the DM.")
166
+ return
167
+ keep_sign_in(npub, prove_by_dm(client, npub, ask, say))
168
+ say("Voice is signed in.")
169
+
170
+
132
171
  # -- pairing ------------------------------------------------------------------
133
172
 
134
173
  def pair(client: mcpclient.Client, base: str, npub: str, proof: str, label: str) -> dict:
@@ -148,16 +187,28 @@ def run(ask: Ask = input, say: Say = print, operator_url: str = OPERATOR_URL) ->
148
187
  token = config.ensure_token()
149
188
 
150
189
  # 1-2. Identity and pairing, unless this Mac is already paired.
190
+ client = mcpclient.Client(base)
151
191
  if cfg.get("agent_id") and cfg.get("agent_secret"):
152
192
  say(f"This Mac is already paired ({cfg['agent_id']}).")
153
- npub = ""
193
+ npub = cfg.get("owner_npub") or ask("Your npub (whose display this is): ").strip()
194
+ if not npub.startswith("npub1"):
195
+ raise SetupError("that is not an npub")
196
+ if has_sign_in(npub):
197
+ config.update(owner_npub=npub)
198
+ say("Voice is already signed in.")
199
+ else:
200
+ voice_sign_in(client, npub, ask, say)
154
201
  else:
155
- client = mcpclient.Client(base)
156
- npub, proof = identity(client, ask, say)
202
+ npub, proof, by_dm = identity(client, ask, say)
157
203
  label = ask("Name this display (e.g. Desk, Office wall): ").strip() or "display"
158
204
  pair(client, base, npub, proof, label)
159
- del proof
160
205
  say(f"Paired as “{label}”.")
206
+ if by_dm:
207
+ keep_sign_in(npub, proof)
208
+ say("Voice is signed in with the same DM.")
209
+ else:
210
+ voice_sign_in(client, npub, ask, say)
211
+ del proof
161
212
 
162
213
  # 3. Tailscale.
163
214
  from . import tailnet
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.2.8
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