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