chartremotely 0.2.0__py3-none-any.whl
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/__init__.py +0 -0
- chartremotely/assets/shortcut-template.plist +1226 -0
- chartremotely/ax.py +278 -0
- chartremotely/cli.py +136 -0
- chartremotely/config.py +68 -0
- chartremotely/doctor.py +118 -0
- chartremotely/keystore.py +101 -0
- chartremotely/layout.py +106 -0
- chartremotely/mcpclient.py +98 -0
- chartremotely/push.py +58 -0
- chartremotely/registry.py +53 -0
- chartremotely/relay.py +145 -0
- chartremotely/resolve.py +207 -0
- chartremotely/scales.py +77 -0
- chartremotely/server.py +69 -0
- chartremotely/services.py +116 -0
- chartremotely/setup.py +202 -0
- chartremotely/shortcut.py +56 -0
- chartremotely/snapshot.py +88 -0
- chartremotely/studies.py +265 -0
- chartremotely/symbol.py +160 -0
- chartremotely/tailnet.py +51 -0
- chartremotely/timeframe.py +210 -0
- chartremotely/vocab.py +140 -0
- chartremotely/window.py +134 -0
- chartremotely-0.2.0.dist-info/METADATA +381 -0
- chartremotely-0.2.0.dist-info/RECORD +31 -0
- chartremotely-0.2.0.dist-info/WHEEL +5 -0
- chartremotely-0.2.0.dist-info/entry_points.txt +2 -0
- chartremotely-0.2.0.dist-info/licenses/LICENSE +202 -0
- chartremotely-0.2.0.dist-info/top_level.txt +1 -0
chartremotely/ax.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""macOS Accessibility and input primitives.
|
|
2
|
+
|
|
3
|
+
Everything awkward about driving thinkorswim lives here so the drivers above
|
|
4
|
+
stay readable. thinkorswim is a Java/Swing application, which breaks several
|
|
5
|
+
reasonable assumptions:
|
|
6
|
+
|
|
7
|
+
* Its accessibility tree is real, but chart children are populated lazily -
|
|
8
|
+
walking ``AXChildren`` misses the symbol field entirely. Hit-testing finds
|
|
9
|
+
it. See :func:`element_at`.
|
|
10
|
+
* ``AXValue`` is read-only on its text fields, while ``AXFocused`` is
|
|
11
|
+
settable. You focus a field and type into it; you cannot write it.
|
|
12
|
+
* Its menu items answer to ``AXPick``, not ``AXPress``.
|
|
13
|
+
* Its aggregation list is a *lightweight* popup painted inside the window.
|
|
14
|
+
It ignores process-targeted mouse events completely, so it needs a real
|
|
15
|
+
HID click - which in turn needs the target to be unobstructed.
|
|
16
|
+
* AWT ignores ``CGEventKeyboardSetUnicodeString``; only real virtual
|
|
17
|
+
keycodes register.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
import Quartz
|
|
25
|
+
from AppKit import NSWorkspace
|
|
26
|
+
from ApplicationServices import (
|
|
27
|
+
AXUIElementCopyActionNames,
|
|
28
|
+
AXUIElementCopyAttributeValue,
|
|
29
|
+
AXUIElementCopyElementAtPosition,
|
|
30
|
+
AXUIElementCreateApplication,
|
|
31
|
+
AXUIElementIsAttributeSettable,
|
|
32
|
+
AXUIElementPerformAction,
|
|
33
|
+
AXUIElementSetAttributeValue,
|
|
34
|
+
AXValueGetValue,
|
|
35
|
+
kAXErrorSuccess,
|
|
36
|
+
kAXValueCGPointType,
|
|
37
|
+
kAXValueCGSizeType,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
APP_NAME = "thinkorswim"
|
|
41
|
+
|
|
42
|
+
# Virtual keycodes. AWT ignores unicode-string events, so every character
|
|
43
|
+
# must be sent as the key a human would press.
|
|
44
|
+
KEYCODE = {
|
|
45
|
+
"a": 0, "b": 11, "c": 8, "d": 2, "e": 14, "f": 3, "g": 5, "h": 4,
|
|
46
|
+
"i": 34, "j": 38, "k": 40, "l": 37, "m": 46, "n": 45, "o": 31, "p": 35,
|
|
47
|
+
"q": 12, "r": 15, "s": 1, "t": 17, "u": 32, "v": 9, "w": 13, "x": 7,
|
|
48
|
+
"y": 16, "z": 6,
|
|
49
|
+
"0": 29, "1": 18, "2": 19, "3": 20, "4": 21,
|
|
50
|
+
"5": 23, "6": 22, "7": 26, "8": 28, "9": 25,
|
|
51
|
+
".": 47, "/": 44, "-": 27, ":": 41, "$": 21,
|
|
52
|
+
}
|
|
53
|
+
KEY_RETURN, KEY_ESCAPE, KEY_DOWN = 36, 53, 125
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class NotRunning(RuntimeError):
|
|
57
|
+
"""thinkorswim is not running."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
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")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def handle(app=None):
|
|
68
|
+
return AXUIElementCreateApplication((app or running_app()).processIdentifier())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def activate(app=None) -> None:
|
|
72
|
+
"""Bring thinkorswim forward, and wait for it to actually be forward.
|
|
73
|
+
|
|
74
|
+
This must happen BEFORE any hit-testing.
|
|
75
|
+
``AXUIElementCopyElementAtPosition`` resolves against the application's
|
|
76
|
+
menu bar - not the window under the point - when the app is not
|
|
77
|
+
frontmost, so every lookup silently returns ``AXMenuBar``.
|
|
78
|
+
"""
|
|
79
|
+
(app or running_app()).activateWithOptions_(2)
|
|
80
|
+
time.sleep(0.6)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def attr(element, name):
|
|
84
|
+
err, value = AXUIElementCopyAttributeValue(element, name, None)
|
|
85
|
+
return value if err == kAXErrorSuccess else None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def settable(element, name) -> bool:
|
|
89
|
+
err, is_settable = AXUIElementIsAttributeSettable(element, name, None)
|
|
90
|
+
return bool(is_settable) if err == kAXErrorSuccess else False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def set_attr(element, name, value) -> bool:
|
|
94
|
+
return AXUIElementSetAttributeValue(element, name, value) == kAXErrorSuccess
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def actions(element) -> list[str]:
|
|
98
|
+
err, names = AXUIElementCopyActionNames(element, None)
|
|
99
|
+
return list(names) if err == kAXErrorSuccess else []
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def perform(element, action) -> bool:
|
|
103
|
+
"""Run an action. Menu items want ``AXPick``; buttons want ``AXPress``."""
|
|
104
|
+
return AXUIElementPerformAction(element, action) == kAXErrorSuccess
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _geo(element, name, kind):
|
|
108
|
+
value = attr(element, name)
|
|
109
|
+
if value is None:
|
|
110
|
+
return None
|
|
111
|
+
ok, out = AXValueGetValue(value, kind, None)
|
|
112
|
+
return out if ok else None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def position(element):
|
|
116
|
+
return _geo(element, "AXPosition", kAXValueCGPointType)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def size(element):
|
|
120
|
+
return _geo(element, "AXSize", kAXValueCGSizeType)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def centre(element) -> tuple[int, int] | None:
|
|
124
|
+
p, s = position(element), size(element)
|
|
125
|
+
return (int(p.x + s.width / 2), int(p.y + s.height / 2)) if p and s else None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def element_at(ax_app, x: float, y: float):
|
|
129
|
+
"""Hit-test a screen point. Requires the app to be frontmost."""
|
|
130
|
+
err, element = AXUIElementCopyElementAtPosition(ax_app, float(x), float(y), None)
|
|
131
|
+
return element if err == kAXErrorSuccess else None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def window(ax_app, title_prefix: str):
|
|
135
|
+
for w in attr(ax_app, "AXWindows") or []:
|
|
136
|
+
if (attr(w, "AXTitle") or "").startswith(title_prefix):
|
|
137
|
+
return w
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def walk(element, visit, depth: int = 0, budget: list[int] | None = None) -> None:
|
|
142
|
+
"""Depth-first walk. Chart subtrees are lazy, so this cannot find
|
|
143
|
+
everything - use it for dialogs, and hit-testing for charts."""
|
|
144
|
+
budget = budget if budget is not None else [80000]
|
|
145
|
+
budget[0] -= 1
|
|
146
|
+
if budget[0] <= 0 or depth > 45:
|
|
147
|
+
return
|
|
148
|
+
visit(element)
|
|
149
|
+
for child in attr(element, "AXChildren") or []:
|
|
150
|
+
walk(child, visit, depth + 1, budget)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def find(root, *, role=None, label=None, predicate=None) -> list:
|
|
154
|
+
"""Collect matching descendants. ``label`` checks title, value and
|
|
155
|
+
description - thinkorswim scatters captions across all three."""
|
|
156
|
+
hits = []
|
|
157
|
+
|
|
158
|
+
def visit(element):
|
|
159
|
+
if role and attr(element, "AXRole") != role:
|
|
160
|
+
return
|
|
161
|
+
if label is not None and label not in (
|
|
162
|
+
attr(element, "AXTitle"), attr(element, "AXValue"),
|
|
163
|
+
attr(element, "AXDescription"),
|
|
164
|
+
):
|
|
165
|
+
return
|
|
166
|
+
if predicate and not predicate(element):
|
|
167
|
+
return
|
|
168
|
+
hits.append(element)
|
|
169
|
+
|
|
170
|
+
walk(root, visit)
|
|
171
|
+
return hits
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# --------------------------------------------------------------------------
|
|
175
|
+
# Input
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def key(code: int, flags: int = 0, pid: int | None = None) -> None:
|
|
179
|
+
"""Send a keystroke, to a process when given a pid.
|
|
180
|
+
|
|
181
|
+
``CGEventPost(kCGHIDEventTap)`` delivers to whatever holds keyboard
|
|
182
|
+
focus, which is not reliably thinkorswim when another window floats
|
|
183
|
+
always-on-top - the keystrokes land in that window instead.
|
|
184
|
+
``CGEventPostToPid`` removes the race.
|
|
185
|
+
"""
|
|
186
|
+
for down in (True, False):
|
|
187
|
+
event = Quartz.CGEventCreateKeyboardEvent(None, code, down)
|
|
188
|
+
Quartz.CGEventSetFlags(event, flags)
|
|
189
|
+
if pid is None:
|
|
190
|
+
Quartz.CGEventPost(Quartz.kCGHIDEventTap, event)
|
|
191
|
+
else:
|
|
192
|
+
Quartz.CGEventPostToPid(pid, event)
|
|
193
|
+
time.sleep(0.012)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def type_text(text: str, pid: int | None = None) -> None:
|
|
197
|
+
for ch in text.lower():
|
|
198
|
+
code = KEYCODE.get(ch)
|
|
199
|
+
if code is None:
|
|
200
|
+
continue
|
|
201
|
+
key(code, Quartz.kCGEventFlagMaskShift if ch in "$:" else 0, pid)
|
|
202
|
+
time.sleep(0.02)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def escape(pid: int) -> None:
|
|
206
|
+
key(KEY_ESCAPE, 0, pid)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# --------------------------------------------------------------------------
|
|
210
|
+
# Occlusion. A real HID click goes to whatever is visually on top, so
|
|
211
|
+
# clicking blind can land in another application entirely.
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _regular_pids() -> set[int]:
|
|
215
|
+
"""Ordinary windowed apps.
|
|
216
|
+
|
|
217
|
+
System overlays - Notification Center above all - keep permanent
|
|
218
|
+
full-screen windows at high layers with alpha 1.0 that are visually
|
|
219
|
+
empty. Counting those as obstructions blocks every click forever.
|
|
220
|
+
"""
|
|
221
|
+
return {a.processIdentifier()
|
|
222
|
+
for a in NSWorkspace.sharedWorkspace().runningApplications()
|
|
223
|
+
if a.activationPolicy() == 0}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def occluders_at(x: float, y: float, tos_pid: int) -> list[int]:
|
|
227
|
+
"""Pids of regular apps whose windows sit above thinkorswim at a point."""
|
|
228
|
+
regular = _regular_pids()
|
|
229
|
+
found, seen_target = [], False
|
|
230
|
+
for w in Quartz.CGWindowListCopyWindowInfo(
|
|
231
|
+
Quartz.kCGWindowListOptionOnScreenOnly, Quartz.kCGNullWindowID):
|
|
232
|
+
pid = w.get("kCGWindowOwnerPID")
|
|
233
|
+
b = w.get("kCGWindowBounds") or {}
|
|
234
|
+
covers = (b.get("X", 0) <= x <= b.get("X", 0) + b.get("Width", 0)
|
|
235
|
+
and b.get("Y", 0) <= y <= b.get("Y", 0) + b.get("Height", 0))
|
|
236
|
+
if pid == tos_pid and covers:
|
|
237
|
+
seen_target = True
|
|
238
|
+
elif (covers and not seen_target and pid in regular
|
|
239
|
+
and w.get("kCGWindowLayer", 0) >= 0 and w.get("kCGWindowAlpha", 1)):
|
|
240
|
+
found.append(pid)
|
|
241
|
+
return list(dict.fromkeys(found))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def hide_apps(pids) -> list:
|
|
245
|
+
apps = [a for a in NSWorkspace.sharedWorkspace().runningApplications()
|
|
246
|
+
if a.processIdentifier() in pids and not a.isHidden()]
|
|
247
|
+
for a in apps:
|
|
248
|
+
a.hide()
|
|
249
|
+
if apps:
|
|
250
|
+
time.sleep(0.6)
|
|
251
|
+
return apps
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def unhide_apps(apps) -> None:
|
|
255
|
+
for a in apps:
|
|
256
|
+
a.unhide()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class Obstructed(RuntimeError):
|
|
260
|
+
"""The click target is covered and the click was refused."""
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def click(x: int, y: int, tos_pid: int) -> None:
|
|
264
|
+
"""Real HID click, refused unless the target is provably unobstructed."""
|
|
265
|
+
blockers = occluders_at(x, y, tos_pid)
|
|
266
|
+
if blockers:
|
|
267
|
+
names = sorted({(a.localizedName() or "?")
|
|
268
|
+
for a in NSWorkspace.sharedWorkspace().runningApplications()
|
|
269
|
+
if a.processIdentifier() in blockers})
|
|
270
|
+
raise Obstructed(f"target at ({x},{y}) is covered by {', '.join(names)}")
|
|
271
|
+
Quartz.CGEventPost(Quartz.kCGHIDEventTap, Quartz.CGEventCreateMouseEvent(
|
|
272
|
+
None, Quartz.kCGEventMouseMoved, Quartz.CGPointMake(x, y),
|
|
273
|
+
Quartz.kCGMouseButtonLeft))
|
|
274
|
+
time.sleep(0.25)
|
|
275
|
+
for kind in (Quartz.kCGEventLeftMouseDown, Quartz.kCGEventLeftMouseUp):
|
|
276
|
+
Quartz.CGEventPost(Quartz.kCGHIDEventTap, Quartz.CGEventCreateMouseEvent(
|
|
277
|
+
None, kind, Quartz.CGPointMake(x, y), Quartz.kCGMouseButtonLeft))
|
|
278
|
+
time.sleep(0.12)
|
chartremotely/cli.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Command line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main(argv: list[str] | None = None) -> int:
|
|
10
|
+
parser = argparse.ArgumentParser(
|
|
11
|
+
prog="chartremotely",
|
|
12
|
+
description="Remote control of desktop charting applications on macOS.")
|
|
13
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
14
|
+
|
|
15
|
+
show = sub.add_parser("show", help="put a security on the chart")
|
|
16
|
+
show.add_argument("name", nargs="+", help="ticker or spoken company name")
|
|
17
|
+
show.add_argument("--scale", default="", help="time frame mnemonic")
|
|
18
|
+
|
|
19
|
+
scale = sub.add_parser("scale", help="change the chart's time frame")
|
|
20
|
+
scale.add_argument("phrase", nargs="+")
|
|
21
|
+
|
|
22
|
+
sub.add_parser("read", help="report the chart's symbol and scale")
|
|
23
|
+
sub.add_parser("scales", help="list the time frames this chart offers")
|
|
24
|
+
sub.add_parser("learn", help="rediscover the chart's controls")
|
|
25
|
+
sub.add_parser("doctor", help="check everything this agent needs")
|
|
26
|
+
sub.add_parser("token", help="print the agent's access token")
|
|
27
|
+
|
|
28
|
+
setup = sub.add_parser("studies", help="rebuild the chart's study set")
|
|
29
|
+
setup.add_argument("--row-height", default=None,
|
|
30
|
+
help="profile row height; match the strike increment (1.0, 2.5)")
|
|
31
|
+
|
|
32
|
+
serve = sub.add_parser("serve", help="run the local listener")
|
|
33
|
+
serve.add_argument("--port", type=int, default=None)
|
|
34
|
+
|
|
35
|
+
pair = sub.add_parser("pair", help="adopt this display to an operator")
|
|
36
|
+
pair.add_argument("--operator", default=None, help="operator base URL")
|
|
37
|
+
|
|
38
|
+
relay = sub.add_parser("relay", help="hold a connection open for the operator")
|
|
39
|
+
relay.add_argument("--operator", default=None)
|
|
40
|
+
|
|
41
|
+
sub.add_parser("setup", help="pair this Mac and make its voice Shortcut, step by step")
|
|
42
|
+
|
|
43
|
+
perms = sub.add_parser("permissions", help="report (or request) Accessibility and Screen Recording")
|
|
44
|
+
perms.add_argument("--request", action="store_true", help="ask macOS to prompt for them")
|
|
45
|
+
perms.add_argument("--out", default=None, help="write the answer as JSON to this file")
|
|
46
|
+
|
|
47
|
+
args = parser.parse_args(argv)
|
|
48
|
+
|
|
49
|
+
# Imported lazily so `doctor` can explain a missing dependency rather
|
|
50
|
+
# than dying on an ImportError.
|
|
51
|
+
if args.command == "doctor":
|
|
52
|
+
from .doctor import report
|
|
53
|
+
return report()
|
|
54
|
+
if args.command == "token":
|
|
55
|
+
from .config import ensure_token
|
|
56
|
+
print(ensure_token())
|
|
57
|
+
return 0
|
|
58
|
+
if args.command == "pair":
|
|
59
|
+
from .relay import pair as do_pair
|
|
60
|
+
print("Give this code to your MCP client: ", end="", flush=True)
|
|
61
|
+
try:
|
|
62
|
+
do_pair(args.operator, on_code=lambda c: print(c, flush=True))
|
|
63
|
+
except Exception as exc: # noqa: BLE001 - surface the reason plainly
|
|
64
|
+
print(f"pairing failed: {exc}")
|
|
65
|
+
return 2
|
|
66
|
+
print("paired")
|
|
67
|
+
return 0
|
|
68
|
+
if args.command == "setup":
|
|
69
|
+
from .setup import SetupError
|
|
70
|
+
from .setup import run as run_setup
|
|
71
|
+
try:
|
|
72
|
+
return run_setup()
|
|
73
|
+
except (SetupError, RuntimeError, OSError) as exc:
|
|
74
|
+
print(f"setup stopped: {exc}")
|
|
75
|
+
print("Fix that, then run `chartremotely setup` again; finished steps are skipped.")
|
|
76
|
+
return 2
|
|
77
|
+
if args.command == "permissions":
|
|
78
|
+
import json
|
|
79
|
+
from pathlib import Path
|
|
80
|
+
|
|
81
|
+
from .services import permissions
|
|
82
|
+
answer = json.dumps(permissions(args.request))
|
|
83
|
+
if args.out:
|
|
84
|
+
Path(args.out).write_text(answer)
|
|
85
|
+
print(answer)
|
|
86
|
+
return 0
|
|
87
|
+
if args.command == "relay":
|
|
88
|
+
from .relay import run
|
|
89
|
+
run(args.operator)
|
|
90
|
+
return 0
|
|
91
|
+
if args.command == "serve":
|
|
92
|
+
from .server import serve as run
|
|
93
|
+
run(args.port)
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
from . import symbol, timeframe
|
|
97
|
+
from .vocab import ERR, cmd_resolve, cmd_set, dispatch
|
|
98
|
+
|
|
99
|
+
if args.command == "show":
|
|
100
|
+
spoken = " ".join(args.name)
|
|
101
|
+
ticker = spoken if _looks_like_ticker(spoken) else cmd_resolve(spoken)
|
|
102
|
+
if ticker.startswith(ERR):
|
|
103
|
+
print(ticker)
|
|
104
|
+
return 2
|
|
105
|
+
print(cmd_set(ticker, args.scale))
|
|
106
|
+
return 0
|
|
107
|
+
if args.command == "scale":
|
|
108
|
+
print(dispatch("scale " + " ".join(args.phrase)))
|
|
109
|
+
return 0
|
|
110
|
+
if args.command == "read":
|
|
111
|
+
print(dispatch("read"))
|
|
112
|
+
return 0
|
|
113
|
+
if args.command == "scales":
|
|
114
|
+
for label in timeframe.presets():
|
|
115
|
+
print(" ", label)
|
|
116
|
+
return 0
|
|
117
|
+
if args.command == "studies":
|
|
118
|
+
from . import studies
|
|
119
|
+
for line in studies.setup(args.row_height):
|
|
120
|
+
print(" ", line)
|
|
121
|
+
return 0
|
|
122
|
+
if args.command == "learn":
|
|
123
|
+
symbol.learn()
|
|
124
|
+
timeframe.learn()
|
|
125
|
+
print("controls rediscovered")
|
|
126
|
+
return 0
|
|
127
|
+
return 1
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _looks_like_ticker(text: str) -> bool:
|
|
131
|
+
from .symbol import TICKER
|
|
132
|
+
return bool(TICKER.match(text.strip().upper()))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
sys.exit(main())
|
chartremotely/config.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""One configuration file, in one place.
|
|
2
|
+
|
|
3
|
+
Replaces the scattered ``~/.config/tos-symbol.json`` and
|
|
4
|
+
``~/.config/tos-chart-token`` of the prototype.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import secrets
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
CONFIG_DIR = Path(os.path.expanduser("~/.config/chartremotely"))
|
|
15
|
+
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
16
|
+
DATA_DIR = Path(os.path.expanduser("~/.local/share/chartremotely"))
|
|
17
|
+
|
|
18
|
+
DEFAULTS: dict = {
|
|
19
|
+
# Window whose title starts with this holds the chart. The build number
|
|
20
|
+
# is deliberately excluded so a thinkorswim update does not break it.
|
|
21
|
+
"window_prefix": "Main@thinkorswim",
|
|
22
|
+
# Offsets are relative to the WINDOW, not the screen, so moving or
|
|
23
|
+
# resizing the window does not invalidate them.
|
|
24
|
+
"symbol_dx": None,
|
|
25
|
+
"symbol_dy": None,
|
|
26
|
+
"aggregation_dx": None,
|
|
27
|
+
"aggregation_dy": None,
|
|
28
|
+
# Row height should match the underlying's strike increment so volume
|
|
29
|
+
# profile buckets land on tradable strikes.
|
|
30
|
+
"row_height": "1.0",
|
|
31
|
+
# The SEC wants a contactable address on registry requests.
|
|
32
|
+
"contact": None,
|
|
33
|
+
# Set once the agent is adopted by an operator.
|
|
34
|
+
"operator_url": None,
|
|
35
|
+
"agent_id": None,
|
|
36
|
+
"agent_secret": None,
|
|
37
|
+
"token": None,
|
|
38
|
+
"port": 8899,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load() -> dict:
|
|
43
|
+
cfg = dict(DEFAULTS)
|
|
44
|
+
if CONFIG_PATH.exists():
|
|
45
|
+
cfg.update(json.loads(CONFIG_PATH.read_text()))
|
|
46
|
+
return cfg
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def save(cfg: dict) -> None:
|
|
50
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
tmp = CONFIG_PATH.with_suffix(".tmp")
|
|
52
|
+
tmp.write_text(json.dumps(cfg, indent=2))
|
|
53
|
+
tmp.chmod(0o600)
|
|
54
|
+
tmp.replace(CONFIG_PATH)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def update(**changes) -> dict:
|
|
58
|
+
cfg = load()
|
|
59
|
+
cfg.update(changes)
|
|
60
|
+
save(cfg)
|
|
61
|
+
return cfg
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def ensure_token() -> str:
|
|
65
|
+
cfg = load()
|
|
66
|
+
if not cfg.get("token"):
|
|
67
|
+
cfg = update(token=secrets.token_urlsafe(32))
|
|
68
|
+
return cfg["token"]
|
chartremotely/doctor.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Check everything the agent needs, and say what to do about each failure.
|
|
2
|
+
|
|
3
|
+
Written for someone who cannot see the screen. When a wall display in
|
|
4
|
+
another building goes quiet, this is the first thing to run - so every
|
|
5
|
+
check names the fix, not just the symptom.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
|
|
13
|
+
from . import config
|
|
14
|
+
|
|
15
|
+
OK, BAD, WARN = " ok ", " FAIL ", " warn "
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _check_pyobjc() -> tuple[str, str]:
|
|
19
|
+
try:
|
|
20
|
+
import Quartz # noqa: F401
|
|
21
|
+
from ApplicationServices import AXUIElementCreateApplication # noqa: F401
|
|
22
|
+
except ImportError:
|
|
23
|
+
return BAD, 'PyObjC missing - pip install "chartremotely[macos]"'
|
|
24
|
+
return OK, "PyObjC present"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _check_app() -> tuple[str, str]:
|
|
28
|
+
from . import ax
|
|
29
|
+
try:
|
|
30
|
+
app = ax.running_app()
|
|
31
|
+
except ax.NotRunning:
|
|
32
|
+
return BAD, "thinkorswim is not running - start it"
|
|
33
|
+
return OK, f"thinkorswim running (pid {app.processIdentifier()})"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _check_accessibility() -> tuple[str, str]:
|
|
37
|
+
"""A denied grant looks like an empty tree, not an error."""
|
|
38
|
+
from . import ax
|
|
39
|
+
try:
|
|
40
|
+
app = ax.running_app()
|
|
41
|
+
except ax.NotRunning:
|
|
42
|
+
return WARN, "skipped - thinkorswim is not running"
|
|
43
|
+
windows = ax.attr(ax.handle(app), "AXWindows")
|
|
44
|
+
if not windows:
|
|
45
|
+
return BAD, ("no accessibility access - grant it in System Settings > "
|
|
46
|
+
"Privacy & Security > Accessibility. This cannot be automated.")
|
|
47
|
+
return OK, f"accessibility granted ({len(windows)} windows visible)"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _check_controls() -> tuple[str, str]:
|
|
51
|
+
from . import ax, symbol, timeframe
|
|
52
|
+
cfg = config.load()
|
|
53
|
+
try:
|
|
54
|
+
ax_app = ax.handle()
|
|
55
|
+
ax.activate()
|
|
56
|
+
except ax.NotRunning:
|
|
57
|
+
return WARN, "skipped - thinkorswim is not running"
|
|
58
|
+
found_symbol = symbol.discover(ax_app, cfg["window_prefix"])
|
|
59
|
+
found_scale = timeframe.discover(ax_app, cfg["window_prefix"])
|
|
60
|
+
if not found_symbol:
|
|
61
|
+
return BAD, "no symbol field found - is a chart visible in the window?"
|
|
62
|
+
if not found_scale:
|
|
63
|
+
return WARN, "symbol field found, but no aggregation control - scale changes unavailable"
|
|
64
|
+
return OK, f"symbol field at {found_symbol}, aggregation at {found_scale}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _check_registry() -> tuple[str, str]:
|
|
68
|
+
from . import registry
|
|
69
|
+
if not registry.CACHE.exists():
|
|
70
|
+
return WARN, "security registry not cached yet - it downloads on first use"
|
|
71
|
+
try:
|
|
72
|
+
rows = registry.load()
|
|
73
|
+
except (OSError, ValueError) as exc:
|
|
74
|
+
return BAD, f"registry unreadable: {exc}"
|
|
75
|
+
if not config.load().get("contact"):
|
|
76
|
+
return WARN, (f"{len(rows)} securities cached, but no contact address set - "
|
|
77
|
+
"the SEC answers 403 without one. Set it in config.json.")
|
|
78
|
+
return OK, f"{len(rows)} securities cached"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _check_listener() -> tuple[str, str]:
|
|
82
|
+
cfg = config.load()
|
|
83
|
+
port = cfg["port"]
|
|
84
|
+
token = cfg.get("token")
|
|
85
|
+
if not token:
|
|
86
|
+
return WARN, "no token yet - it is generated when the listener first starts"
|
|
87
|
+
request = urllib.request.Request(
|
|
88
|
+
f"http://127.0.0.1:{port}/chart", data=b"read",
|
|
89
|
+
headers={"X-Token": token}, method="POST")
|
|
90
|
+
try:
|
|
91
|
+
with urllib.request.urlopen(request, timeout=5) as response:
|
|
92
|
+
return OK, f"listener answering on :{port} - {response.read().decode().strip()}"
|
|
93
|
+
except urllib.error.URLError:
|
|
94
|
+
return WARN, f"nothing listening on :{port} - run: chartremotely serve"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
CHECKS = (
|
|
98
|
+
("PyObjC", _check_pyobjc),
|
|
99
|
+
("thinkorswim", _check_app),
|
|
100
|
+
("Accessibility", _check_accessibility),
|
|
101
|
+
("Chart controls", _check_controls),
|
|
102
|
+
("Registry", _check_registry),
|
|
103
|
+
("Listener", _check_listener),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def report() -> int:
|
|
108
|
+
"""Print every check. Returns non-zero if any hard check failed."""
|
|
109
|
+
failed = False
|
|
110
|
+
for name, check in CHECKS:
|
|
111
|
+
try:
|
|
112
|
+
status, detail = check()
|
|
113
|
+
except Exception as exc: # noqa: BLE001 - a doctor must never crash
|
|
114
|
+
status, detail = BAD, f"check raised: {exc}"
|
|
115
|
+
if status == BAD:
|
|
116
|
+
failed = True
|
|
117
|
+
print(f"[{status}] {name:<16} {detail}")
|
|
118
|
+
return 1 if failed else 0
|