chartremotely 0.2.8__tar.gz → 0.2.10__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 (54) hide show
  1. {chartremotely-0.2.8 → chartremotely-0.2.10}/PKG-INFO +26 -9
  2. {chartremotely-0.2.8 → chartremotely-0.2.10}/README.md +25 -8
  3. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/assets/shortcut-template.plist +0 -11
  4. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/ax.py +19 -5
  5. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/config.py +3 -0
  6. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/keystore.py +42 -8
  7. chartremotely-0.2.10/chartremotely/patron.py +194 -0
  8. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/requestlog.py +21 -0
  9. chartremotely-0.2.10/chartremotely/screen.py +116 -0
  10. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/server.py +20 -14
  11. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/setup.py +60 -9
  12. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/vocab.py +25 -7
  13. chartremotely-0.2.10/chartremotely/window.py +166 -0
  14. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely.egg-info/PKG-INFO +26 -9
  15. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely.egg-info/SOURCES.txt +4 -2
  16. {chartremotely-0.2.8 → chartremotely-0.2.10}/pyproject.toml +1 -1
  17. chartremotely-0.2.10/tests/test_patron.py +275 -0
  18. {chartremotely-0.2.8 → chartremotely-0.2.10}/tests/test_relay.py +2 -1
  19. chartremotely-0.2.10/tests/test_screen.py +77 -0
  20. {chartremotely-0.2.8 → chartremotely-0.2.10}/tests/test_setup.py +62 -6
  21. chartremotely-0.2.8/chartremotely/forward.py +0 -79
  22. chartremotely-0.2.8/chartremotely/window.py +0 -134
  23. chartremotely-0.2.8/tests/test_forward.py +0 -203
  24. {chartremotely-0.2.8 → chartremotely-0.2.10}/LICENSE +0 -0
  25. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/__init__.py +0 -0
  26. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/answer.py +0 -0
  27. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/cli.py +0 -0
  28. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/doctor.py +0 -0
  29. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/guilock.py +0 -0
  30. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/layout.py +0 -0
  31. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/mcpclient.py +0 -0
  32. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/push.py +0 -0
  33. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/registry.py +0 -0
  34. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/relay.py +0 -0
  35. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/resolve.py +0 -0
  36. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/scales.py +0 -0
  37. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/services.py +0 -0
  38. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/shortcut.py +0 -0
  39. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/snapshot.py +0 -0
  40. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/studies.py +0 -0
  41. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/symbol.py +0 -0
  42. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/tailnet.py +0 -0
  43. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely/timeframe.py +0 -0
  44. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely.egg-info/dependency_links.txt +0 -0
  45. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely.egg-info/entry_points.txt +0 -0
  46. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely.egg-info/requires.txt +0 -0
  47. {chartremotely-0.2.8 → chartremotely-0.2.10}/chartremotely.egg-info/top_level.txt +0 -0
  48. {chartremotely-0.2.8 → chartremotely-0.2.10}/setup.cfg +0 -0
  49. {chartremotely-0.2.8 → chartremotely-0.2.10}/tests/test_push.py +0 -0
  50. {chartremotely-0.2.8 → chartremotely-0.2.10}/tests/test_requestlog.py +0 -0
  51. {chartremotely-0.2.8 → chartremotely-0.2.10}/tests/test_resolve.py +0 -0
  52. {chartremotely-0.2.8 → chartremotely-0.2.10}/tests/test_snapshot.py +0 -0
  53. {chartremotely-0.2.8 → chartremotely-0.2.10}/tests/test_stamp_changelog.py +0 -0
  54. {chartremotely-0.2.8 → chartremotely-0.2.10}/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.10
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>
@@ -22,7 +22,7 @@ from __future__ import annotations
22
22
  import time
23
23
 
24
24
  import Quartz
25
- from AppKit import NSWorkspace
25
+ from AppKit import NSRunningApplication, NSWorkspace
26
26
  from ApplicationServices import (
27
27
  AXUIElementCopyActionNames,
28
28
  AXUIElementCopyAttributeValue,
@@ -57,11 +57,25 @@ class NotRunning(RuntimeError):
57
57
  """thinkorswim is not running."""
58
58
 
59
59
 
60
+ def app_for(pid: int):
61
+ """The running application with ``pid``, looked up now; None if gone.
62
+
63
+ Never from ``NSWorkspace.runningApplications()``: that list only
64
+ refreshes on a run loop, which the listener and relay never spin, so an
65
+ app launched (or quit) after they started is missing from it (or lingers).
66
+ """
67
+ return NSRunningApplication.runningApplicationWithProcessIdentifier_(pid)
68
+
69
+
60
70
  def running_app():
61
- for a in NSWorkspace.sharedWorkspace().runningApplications():
62
- if APP_NAME.lower() in (a.localizedName() or "").lower():
63
- return a
64
- raise NotRunning(f"{APP_NAME} is not running")
71
+ """thinkorswim, found through the window server's live list of windows."""
72
+ for w in Quartz.CGWindowListCopyWindowInfo(
73
+ Quartz.kCGWindowListOptionAll, Quartz.kCGNullWindowID) or []:
74
+ if APP_NAME.lower() in (w.get("kCGWindowOwnerName") or "").lower():
75
+ app = app_for(w["kCGWindowOwnerPID"])
76
+ if app is not None:
77
+ return app
78
+ raise NotRunning(f"{APP_NAME} is not open on this Mac")
65
79
 
66
80
 
67
81
  def handle(app=None):
@@ -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,24 @@ 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
54
+
55
+
56
+ def note(source: str, text: str) -> None:
57
+ """One free-form line, e.g. what it took to bring the chart in front. Never raises."""
58
+ try:
59
+ print(f"{time.strftime('%Y-%m-%dT%H:%M:%S%z')} {source} {_clean(text, ERR_MAX)}",
60
+ file=sys.stderr, flush=True)
61
+ except (OSError, ValueError):
62
+ pass
@@ -0,0 +1,116 @@
1
+ """Decisions about what is on screen, made from a snapshot of the window list.
2
+
3
+ Getting the chart in front of someone takes macOS calls - unhide, activate,
4
+ un-minimise, raise, hide what covers it - but deciding WHICH window is the
5
+ chart, WHETHER it is on screen yet and WHAT covers it needs none of them. That
6
+ half lives here, over plain dicts shaped like Quartz's window list, so it runs
7
+ (and is tested) where PyObjC is absent. :mod:`window` feeds it.
8
+
9
+ A window is a dict with ``kCGWindowOwnerPID``, ``kCGWindowLayer`` and
10
+ ``kCGWindowBounds`` (``X``, ``Y``, ``Width``, ``Height``), as
11
+ ``CGWindowListCopyWindowInfo`` reports it: front to back, on-screen only.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import time
17
+ from collections.abc import Callable, Iterable
18
+ from dataclasses import dataclass
19
+
20
+ Frame = tuple[float, float, float, float]
21
+
22
+ #: Points of slack when matching the accessibility frame to the window list.
23
+ TOLERANCE = 2.0
24
+
25
+
26
+ def overlaps(a: dict, b: dict) -> bool:
27
+ """Whether two bounds dicts share any area."""
28
+ return not (a.get("X", 0) + a.get("Width", 0) <= b.get("X", 0)
29
+ or a.get("X", 0) >= b.get("X", 0) + b.get("Width", 0)
30
+ or a.get("Y", 0) + a.get("Height", 0) <= b.get("Y", 0)
31
+ or a.get("Y", 0) >= b.get("Y", 0) + b.get("Height", 0))
32
+
33
+
34
+ def largest(windows: Iterable[dict], pid: int) -> dict | None:
35
+ """The largest window ``pid`` has on screen."""
36
+ best, best_area = None, -1.0
37
+ for w in windows:
38
+ if w.get("kCGWindowOwnerPID") != pid:
39
+ continue
40
+ b = w.get("kCGWindowBounds") or {}
41
+ area = b.get("Width", 0) * b.get("Height", 0)
42
+ if area > best_area:
43
+ best, best_area = w, area
44
+ return best
45
+
46
+
47
+ def showing(windows: Iterable[dict], pid: int, frame: Frame) -> dict | None:
48
+ """The on-screen window of ``pid`` whose bounds are ``frame``, or None.
49
+
50
+ ``frame`` is the chart window's (x, y, w, h) as the accessibility tree
51
+ reports it. Matching on it, rather than taking the app's largest window,
52
+ means a detached thinkorswim window left on screen does not count as the
53
+ chart being back.
54
+ """
55
+ for w in windows:
56
+ if w.get("kCGWindowOwnerPID") != pid:
57
+ continue
58
+ b = w.get("kCGWindowBounds") or {}
59
+ got = (b.get("X", 0), b.get("Y", 0), b.get("Width", 0), b.get("Height", 0))
60
+ if all(abs(g - f) <= TOLERANCE for g, f in zip(got, frame, strict=True)):
61
+ return w
62
+ return None
63
+
64
+
65
+ def covering(windows: Iterable[dict], bounds: dict, pid: int,
66
+ regular: Iterable[int]) -> list[int]:
67
+ """Pids of ordinary apps with a window overlapping ``bounds``.
68
+
69
+ Only ``regular`` apps count: system overlays keep permanent full-screen
70
+ windows that are neither hideable nor actually in the way. Negative
71
+ layers are desktop furniture, never in front of the chart.
72
+ """
73
+ allowed = set(regular) - {pid}
74
+ found = [w["kCGWindowOwnerPID"] for w in windows
75
+ if w.get("kCGWindowOwnerPID") in allowed
76
+ and w.get("kCGWindowLayer", 0) >= 0
77
+ and overlaps(w.get("kCGWindowBounds") or {}, bounds)]
78
+ return list(dict.fromkeys(found))
79
+
80
+
81
+ def wait_for(probe: Callable[[], object], timeout: float = 2.0, interval: float = 0.1,
82
+ clock: Callable[[], float] = time.monotonic,
83
+ sleep: Callable[[float], None] = time.sleep):
84
+ """Poll ``probe`` until it returns something truthy, for at most ``timeout`` seconds.
85
+
86
+ Returns that value, or None when time ran out. Always probes at least once.
87
+ """
88
+ deadline = clock() + timeout
89
+ while True:
90
+ found = probe()
91
+ if found:
92
+ return found
93
+ if clock() >= deadline:
94
+ return None
95
+ sleep(interval)
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class Presented:
100
+ """What it took to get the chart in front of the viewer."""
101
+
102
+ unhid: bool = False
103
+ unminimized: bool = False
104
+ raised: bool = False
105
+ hid: tuple[str, ...] = ()
106
+
107
+ def __bool__(self) -> bool:
108
+ return self.unhid or self.unminimized or self.raised or bool(self.hid)
109
+
110
+ def __str__(self) -> str:
111
+ done = [word for word, did in (("unhid", self.unhid),
112
+ ("unminimized", self.unminimized),
113
+ ("raised", self.raised)) if did]
114
+ if self.hid:
115
+ done.append("hid " + ", ".join(self.hid))
116
+ return "; ".join(done) or "already in front"
@@ -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>``.