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.
@@ -0,0 +1,56 @@
1
+ """Make this Mac's own copy of the ChartRemotely voice Shortcut.
2
+
3
+ The template is the working Shortcut itself, exported with two placeholders
4
+ where its address and token were. Filling them in is string substitution
5
+ and nothing more: rebuilding a Shortcut's steps by hand has twice produced
6
+ one that Siri runs differently from the original.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import plistlib
12
+ import subprocess
13
+ import tempfile
14
+ from importlib import resources
15
+ from pathlib import Path
16
+
17
+ URL_MARK = "__CHARTREMOTELY_URL__"
18
+ TOKEN_MARK = "__CHARTREMOTELY_TOKEN__"
19
+ NAME = "ChartRemotely"
20
+
21
+
22
+ def template() -> bytes:
23
+ return resources.files("chartremotely").joinpath("assets/shortcut-template.plist").read_bytes()
24
+
25
+
26
+ def fill(raw: bytes, url: str, token: str) -> dict:
27
+ """The template with its placeholders replaced, as a Shortcut workflow."""
28
+ for value, mark in ((url, URL_MARK), (token, TOKEN_MARK)):
29
+ if not value or mark in value:
30
+ raise ValueError(f"no value for {mark}")
31
+ workflow = plistlib.loads(raw)
32
+
33
+ def walk(node):
34
+ if isinstance(node, dict):
35
+ return {k: walk(v) for k, v in node.items()}
36
+ if isinstance(node, list):
37
+ return [walk(v) for v in node]
38
+ if isinstance(node, str):
39
+ return node.replace(URL_MARK, url).replace(TOKEN_MARK, token)
40
+ return node
41
+
42
+ return walk(workflow)
43
+
44
+
45
+ def build(url: str, token: str, out_dir: Path | None = None) -> Path:
46
+ """Write, sign and return this Mac's Shortcut. The file name is its library name."""
47
+ folder = Path(out_dir or tempfile.mkdtemp(prefix="chartremotely-"))
48
+ unsigned = folder / "unsigned.shortcut"
49
+ signed = folder / f"{NAME}.shortcut"
50
+ unsigned.write_bytes(plistlib.dumps(fill(template(), url, token), fmt=plistlib.FMT_BINARY))
51
+ try:
52
+ subprocess.run(["shortcuts", "sign", "-m", "anyone", "-i", str(unsigned), "-o", str(signed)],
53
+ capture_output=True, check=True)
54
+ finally:
55
+ unsigned.unlink(missing_ok=True)
56
+ return signed
@@ -0,0 +1,88 @@
1
+ """Crop a capture to the chart, and shrink it into something that can travel.
2
+
3
+ Only the chart's own pane leaves the machine. A thinkorswim window also shows
4
+ the account number, net liquidation value, buying power and cash, and a
5
+ snapshot is a picture sent to wherever the patron is — so the crop is the
6
+ point, not a nicety. When the chart's pane cannot be told apart from the whole
7
+ window, there is no snapshot: failing closed is the only safe answer.
8
+
9
+ A window capture on a Retina display is a multi-megabyte PNG. It crosses the
10
+ relay as text inside a JSON reply and sits for a moment in the operator's
11
+ database, so it is re-encoded as a modest JPEG first: enough to read the
12
+ chart, small enough not to matter.
13
+
14
+ Pure on purpose. The capture itself needs the macOS drivers; turning bytes
15
+ into a reply does not, so this module stays importable - and testable -
16
+ where PyObjC is absent. ``sips`` is the system's own image tool, which keeps
17
+ the dependency list empty.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+ import subprocess
24
+ import tempfile
25
+ from pathlib import Path
26
+
27
+ #: Longest side of the image sent back, in pixels.
28
+ MAX_SIDE = 1600
29
+ #: JPEG quality, 0-100.
30
+ QUALITY = 70
31
+ #: What every successful reply starts with. The operator checks for it.
32
+ PREFIX = "data:image/jpeg;base64,"
33
+
34
+ Rect = tuple[int, int, int, int]
35
+
36
+
37
+ class ChartNotIsolated(LookupError):
38
+ """The chart's own pane could not be told apart from the whole window."""
39
+
40
+
41
+ def chart_crop(hit: tuple[int, int], panes: list[Rect], window: Rect,
42
+ image: tuple[int, int]) -> Rect:
43
+ """The chart's pane, in the capture's own pixels.
44
+
45
+ ``hit`` is a point inside the chart (its symbol field), ``panes`` the
46
+ containers the accessibility tree reports and ``window`` the window, all
47
+ in screen points. ``image`` is the capture's size in pixels, which differs
48
+ from the window's size in points on a Retina display.
49
+
50
+ The chart's pane is the smallest container around the symbol field. The
51
+ window itself never counts: sending it would send the account panel.
52
+ """
53
+ wx, wy, ww, wh = window
54
+ hx, hy = hit
55
+ inside = [r for r in panes
56
+ if r[0] <= hx < r[0] + r[2] and r[1] <= hy < r[1] + r[3]
57
+ and r[2] * r[3] < ww * wh]
58
+ if not inside:
59
+ raise ChartNotIsolated("could not tell the chart apart from the rest of the window")
60
+ x, y, w, h = min(inside, key=lambda r: r[2] * r[3])
61
+ sx, sy = image[0] / ww, image[1] / wh
62
+ left, top = max(0, round((x - wx) * sx)), max(0, round((y - wy) * sy))
63
+ right = min(image[0], round((x - wx + w) * sx))
64
+ bottom = min(image[1], round((y - wy + h) * sy))
65
+ return left, top, right - left, bottom - top
66
+
67
+
68
+ def sips_argv(src: Path, dst: Path, max_side: int = MAX_SIDE,
69
+ quality: int = QUALITY) -> list[str]:
70
+ """The ``sips`` command that resizes and re-encodes one capture."""
71
+ return ["sips", "-Z", str(max_side),
72
+ "-s", "format", "jpeg",
73
+ "-s", "formatOptions", str(quality),
74
+ str(src), "--out", str(dst)]
75
+
76
+
77
+ def as_reply(jpeg: bytes) -> str:
78
+ """A JPEG, as the one-line text reply the relay carries."""
79
+ return PREFIX + base64.b64encode(jpeg).decode("ascii")
80
+
81
+
82
+ def shrink(png: bytes) -> bytes:
83
+ """Re-encode a PNG capture as a downscaled JPEG."""
84
+ with tempfile.TemporaryDirectory() as tmp:
85
+ src, dst = Path(tmp) / "chart.png", Path(tmp) / "chart.jpg"
86
+ src.write_bytes(png)
87
+ subprocess.run(sips_argv(src, dst), check=True, capture_output=True)
88
+ return dst.read_bytes()
@@ -0,0 +1,265 @@
1
+ """Manage the chart's studies.
2
+
3
+ Not on the voice path - this is the one-time setup that puts a useful chart
4
+ on the wall. It drives thinkorswim's Edit Studies dialog, which behaves
5
+ differently from the chart itself in several ways worth knowing:
6
+
7
+ * the Studies toolbar control is a toggle that can be left "on" with no menu
8
+ showing, so a blind press closes it instead of opening it
9
+ * its menu items answer to ``AXPick``
10
+ * the study list rows answer to NEITHER press nor keyboard - selecting one
11
+ needs a real click, and "Add selected" stays greyed until something is
12
+ genuinely selected
13
+ * button captions live in ``AXDescription``, not ``AXTitle``
14
+ * its combo boxes are not settable, but accept type-ahead once focused:
15
+ send "d" and CHART becomes DAY
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import time
21
+
22
+ from . import ax, config, layout
23
+
24
+ DIALOG = "Edit Studies and Strategies"
25
+
26
+ # A chart for reading structure: where price traded, whether volatility is
27
+ # compressed, and the session's volume-weighted mean.
28
+ PRESET = (
29
+ ("VolumeProfile", None),
30
+ ("squeeze", "TTM_Squeeze"),
31
+ ("vwap", "VWAP"), # the period-resetting one, NOT AnchoredVWAP
32
+ )
33
+
34
+
35
+ class DialogError(RuntimeError):
36
+ """The studies dialog could not be opened or driven."""
37
+
38
+
39
+ def _dialog(ax_app):
40
+ for window in ax.attr(ax_app, "AXWindows") or []:
41
+ if (ax.attr(window, "AXTitle") or "") == DIALOG:
42
+ return window
43
+ return None
44
+
45
+
46
+ def _labelled(element, caption: str) -> bool:
47
+ return caption in (ax.attr(element, "AXTitle"), ax.attr(element, "AXDescription"))
48
+
49
+
50
+ def _button(root, caption: str):
51
+ hits = ax.find(root, role="AXButton", predicate=lambda el: _labelled(el, caption))
52
+ return hits[0] if hits else None
53
+
54
+
55
+ def open_dialog(app=None):
56
+ """Studies toolbar -> "Edit studies...". Returns the dialog element."""
57
+ app = app or ax.running_app()
58
+ ax.activate(app)
59
+ ax_app = ax.handle(app)
60
+ if _dialog(ax_app) is not None:
61
+ return _dialog(ax_app)
62
+
63
+ prefix = config.load()["window_prefix"]
64
+ spot = layout.find_in_panes(
65
+ ax_app, prefix,
66
+ lambda el: el is not None and ax.attr(el, "AXRole") == "AXCheckBox"
67
+ and _labelled(el, "Studies"))
68
+ if spot is None:
69
+ raise DialogError("Studies control not found; is a chart visible?")
70
+
71
+ toggle = ax.element_at(ax_app, *spot)
72
+ # Cycle a stuck-open toggle: pressing it while already "on" would close
73
+ # the menu, and the stale items left behind do not respond to AXPick.
74
+ if ax.attr(toggle, "AXValue") in (0, "0", False, None):
75
+ ax.perform(toggle, "AXPress")
76
+ time.sleep(1.4)
77
+ else:
78
+ ax.perform(toggle, "AXPress")
79
+ time.sleep(0.8)
80
+ ax.perform(toggle, "AXPress")
81
+ time.sleep(1.4)
82
+
83
+ item = None
84
+ for window in ax.attr(ax_app, "AXWindows") or []:
85
+ for candidate in ax.find(window, role="AXMenuItem"):
86
+ label = (ax.attr(candidate, "AXTitle") or ax.attr(candidate, "AXValue")
87
+ or ax.attr(candidate, "AXDescription") or "")
88
+ if str(label).strip().lower().startswith("edit studies"):
89
+ item = candidate
90
+ break
91
+ if item:
92
+ break
93
+ if item is None:
94
+ raise DialogError("'Edit studies...' not found in the Studies menu")
95
+
96
+ ax.perform(item, "AXPick") # menu items take AXPick, not AXPress
97
+ time.sleep(3.0)
98
+ dialog = _dialog(ax_app)
99
+ if dialog is None:
100
+ raise DialogError("the studies dialog did not open")
101
+ return dialog
102
+
103
+
104
+ def applied(ax_app=None) -> list[str]:
105
+ """Studies currently on the chart."""
106
+ dialog = _dialog(ax_app or ax.handle())
107
+ if dialog is None:
108
+ return []
109
+ names = []
110
+ for element in ax.find(dialog, role="AXStaticText"):
111
+ value = ax.attr(element, "AXValue")
112
+ if isinstance(value, str) and "(" in value and value.strip():
113
+ names.append(value.strip()[:46])
114
+ return list(dict.fromkeys(names)) # the dialog renders each row twice
115
+
116
+
117
+ def _row_label(row) -> str:
118
+ found = []
119
+ ax.walk(row, lambda el: found.append(
120
+ ax.attr(el, "AXValue") or ax.attr(el, "AXTitle") or ax.attr(el, "AXDescription")))
121
+ return next((str(v).strip() for v in found if isinstance(v, str) and v.strip()), "")
122
+
123
+
124
+ def add(search: str, exact: str | None = None, app=None) -> bool:
125
+ """Filter the catalogue and add one study.
126
+
127
+ ``exact`` matters: the list is alphabetical, so searching "vwap" offers
128
+ AnchoredVWAP first and a naive "take the first row" picks the wrong one.
129
+ """
130
+ app = app or ax.running_app()
131
+ pid = app.processIdentifier()
132
+ ax_app = ax.handle(app)
133
+ dialog = _dialog(ax_app)
134
+ if dialog is None:
135
+ raise DialogError("the studies dialog is not open")
136
+ want = (exact or search).lower()
137
+
138
+ # The filter field is the text field inside a combo box - the same
139
+ # distinctive shape as the chart's symbol entry. Picking "the first wide
140
+ # text field" grabs something else and the catalogue never filters,
141
+ # leaving the category tree ("All Studies", "Alpha Studies") in view.
142
+ def _is_filter(element):
143
+ parent = ax.attr(element, "AXParent")
144
+ return bool(parent is not None and ax.attr(parent, "AXRole") == "AXComboBox")
145
+
146
+ fields = ax.find(dialog, role="AXTextField", predicate=_is_filter)
147
+ if not fields:
148
+ raise DialogError("the study filter field was not found")
149
+ ax.set_attr(fields[0], "AXFocused", True)
150
+ time.sleep(0.3)
151
+ ax.key(ax.KEYCODE["a"], 1 << 20, pid) # command-A
152
+ time.sleep(0.1)
153
+ ax.type_text(search, pid)
154
+ time.sleep(1.2)
155
+
156
+ rows = ax.find(dialog, role="AXRow",
157
+ predicate=lambda el: (ax.position(el) or type("", (), {"x": 9e9})).x < 900)
158
+ target = next((r for r in rows if _row_label(r).lower() == want), None)
159
+ if target is None:
160
+ raise DialogError(f"no study named {want!r}; offered: "
161
+ f"{[_row_label(r) for r in rows][:5]}")
162
+ button = _button(dialog, "Add selected")
163
+ if button is None:
164
+ raise DialogError("'Add selected' not found")
165
+
166
+ # Rows answer to neither AXPress nor the keyboard: only a real click
167
+ # selects one, and until one is selected the button stays greyed.
168
+ spot, target_spot = ax.centre(button), ax.centre(target)
169
+ blockers = ax.occluders_at(*target_spot, pid) + ax.occluders_at(*spot, pid)
170
+ hidden = ax.hide_apps(list(dict.fromkeys(blockers)))
171
+ try:
172
+ ax.click(*target_spot, pid)
173
+ time.sleep(0.6)
174
+ ax.click(*spot, pid)
175
+ time.sleep(1.4)
176
+ finally:
177
+ ax.unhide_apps(hidden)
178
+ return True
179
+
180
+
181
+ def press(caption: str, app=None) -> bool:
182
+ """Press a labelled button in the dialog."""
183
+ app = app or ax.running_app()
184
+ dialog = _dialog(ax.handle(app))
185
+ button = _button(dialog, caption) if dialog else None
186
+ if button is None:
187
+ return False
188
+ return ax.perform(button, "AXPress")
189
+
190
+
191
+ def setup(row_height: str | None = None, per: str = "CHART", app=None) -> list[str]:
192
+ """Rebuild the study set from scratch. Idempotent.
193
+
194
+ ``row_height`` should match the underlying's strike increment so profile
195
+ buckets land on tradable strikes.
196
+ """
197
+ app = app or ax.running_app()
198
+ cfg = config.load()
199
+ height = row_height or cfg["row_height"]
200
+ open_dialog(app)
201
+ press("Remove all", app)
202
+ time.sleep(0.8)
203
+ for search, exact in PRESET:
204
+ add(search, exact, app)
205
+ configure_volume_profile(height, per, app)
206
+ # Read the result BEFORE closing: applied() inspects the dialog, which
207
+ # no longer exists once OK is pressed.
208
+ result = applied()
209
+ press("OK", app)
210
+ time.sleep(2.5)
211
+ config.update(row_height=height)
212
+ return result
213
+
214
+
215
+ def configure_volume_profile(row_height: str = "1.0", per: str = "CHART", app=None) -> bool:
216
+ """Set the profile's row height and period in its customiser."""
217
+ app = app or ax.running_app()
218
+ ax_app = ax.handle(app)
219
+ dialog = _dialog(ax_app)
220
+ if dialog is None:
221
+ raise DialogError("the studies dialog is not open")
222
+
223
+ rows = [el for el in ax.find(dialog, role="AXStaticText")
224
+ if str(ax.attr(el, "AXValue") or "").startswith("VolumeProfile")]
225
+ if not rows:
226
+ raise DialogError("VolumeProfile is not in the applied list")
227
+ # Find the row's settings control structurally: the buttons sharing its
228
+ # vertical band, rightmost last. A fixed pixel offset from the label
229
+ # would break the moment the dialog is resized or relaid out.
230
+ row_spot = ax.centre(rows[0])
231
+
232
+ def _same_band(element):
233
+ p, s = ax.position(element), ax.size(element)
234
+ return bool(p and s and p.y <= row_spot[1] <= p.y + s.height
235
+ and p.x > row_spot[0])
236
+
237
+ controls = sorted(ax.find(dialog, role="AXButton", predicate=_same_band),
238
+ key=lambda el: ax.position(el).x)
239
+ if not controls:
240
+ raise DialogError("the VolumeProfile settings control was not found")
241
+ gear = controls[-1]
242
+ ax.perform(gear, "AXPress")
243
+ time.sleep(2.5)
244
+
245
+ sheet = next((w for w in ax.attr(ax_app, "AXWindows") or []
246
+ if (ax.attr(w, "AXTitle") or "").startswith("VolumeProfile")), None)
247
+ if sheet is None:
248
+ raise DialogError("the VolumeProfile customiser did not open")
249
+
250
+ combos = sorted(ax.find(sheet, role="AXComboBox"),
251
+ key=lambda el: (ax.position(el).y, ax.position(el).x))
252
+ if len(combos) < 3:
253
+ raise DialogError("unexpected customiser layout")
254
+ # Combos are not settable, but type-ahead works once focused.
255
+ for combo, letter in ((combos[0], "c"), (combos[2], per[0].lower())):
256
+ ax.set_attr(combo, "AXFocused", True)
257
+ time.sleep(0.35)
258
+ ax.key(ax.KEYCODE[letter], 0, app.processIdentifier())
259
+ time.sleep(0.7)
260
+
261
+ ok = _button(sheet, "OK")
262
+ if ok is not None:
263
+ ax.perform(ok, "AXPress")
264
+ time.sleep(2.0)
265
+ return True
@@ -0,0 +1,160 @@
1
+ """Drive the chart's symbol field.
2
+
3
+ ``AXValue`` on that field is read-only, so the symbol cannot be written
4
+ directly. ``AXFocused`` *is* settable, so the field is focused and typed
5
+ into - which is also why the vocabulary can be kept narrow: this module can
6
+ only ever put text in one box.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import time
13
+
14
+ import Quartz
15
+
16
+ from . import ax, config, layout
17
+
18
+ # Accepts tickers, futures (/ES), indices (.SPX) and share classes (BRK/B).
19
+ TICKER = re.compile(r"^[./$]?[A-Z]{1,6}([/.][A-Z]{1,2})?(:[A-Z]+)?$")
20
+
21
+ class NotFound(RuntimeError):
22
+ """The symbol field could not be located or did not validate."""
23
+
24
+
25
+ def to_ticker(text: str) -> str:
26
+ """Normalise a ticker. Name resolution happens in :mod:`resolve`."""
27
+ t = re.sub(r"[^A-Z0-9./$-]", "", text.strip().upper())
28
+ # The SEC writes share classes as BRK-B; thinkorswim wants BRK/B.
29
+ return re.sub(r"-([A-Z]{1,2})$", r"/\1", t)
30
+
31
+
32
+ def validate(element) -> tuple[bool, str]:
33
+ """True only for something that really is a symbol entry field.
34
+
35
+ This is the guard that keeps a stale offset from typing a ticker into
36
+ whatever else happens to be at those coordinates.
37
+ """
38
+ if element is None or ax.attr(element, "AXRole") != "AXTextField":
39
+ return False, "not a text field"
40
+ parent = ax.attr(element, "AXParent")
41
+ if parent is None or ax.attr(parent, "AXRole") != "AXComboBox":
42
+ return False, "parent is not a combo box"
43
+ if not ax.settable(element, "AXFocused"):
44
+ return False, "field does not accept focus"
45
+ value = ax.attr(element, "AXValue")
46
+ if not isinstance(value, str) or not TICKER.match(value.strip()):
47
+ return False, f"current value {value!r} is not ticker-shaped"
48
+ return True, value.strip()
49
+
50
+
51
+ def discover(ax_app, window_prefix: str) -> tuple[int, int] | None:
52
+ """Find the symbol field without assuming where the chart is.
53
+
54
+ Searches each pane the accessibility tree reports, relative to that
55
+ pane's own bounds. No screen coordinates are assumed, so a user whose
56
+ chart sits bottom-right - or who rearranges it at lunchtime - is found
57
+ the same way.
58
+ """
59
+ return layout.find_in_panes(
60
+ ax_app, window_prefix, lambda el: validate(el)[0])
61
+
62
+
63
+ def discover_all(ax_app, window_prefix: str) -> list[tuple[int, int]]:
64
+ """Every symbol field on screen, for layouts holding several charts."""
65
+ hits = []
66
+ for rect in layout.panes(ax_app, window_prefix):
67
+ hits += layout.scan_all(ax_app, rect, lambda el: validate(el)[0])
68
+ return hits
69
+
70
+
71
+ def learn(app=None) -> dict:
72
+ """Locate the field and record its window-relative offset."""
73
+ app = app or ax.running_app()
74
+ ax.activate(app)
75
+ ax_app = ax.handle(app)
76
+ cfg = config.load()
77
+ found = discover(ax_app, cfg["window_prefix"])
78
+ if found is None:
79
+ raise NotFound("no symbol field found; is a chart visible?")
80
+ win = ax.window(ax_app, cfg["window_prefix"])
81
+ if win is None:
82
+ raise NotFound(f"no window titled {cfg['window_prefix']!r}")
83
+ origin = ax.position(win)
84
+ return config.update(symbol_dx=int(found[0] - origin.x),
85
+ symbol_dy=int(found[1] - origin.y))
86
+
87
+
88
+ def locate(ax_app, cfg: dict):
89
+ """Resolve the field through the window's CURRENT position.
90
+
91
+ The stored offset only proposes where to look; validation decides
92
+ whether to act. A small ring absorbs minor layout drift, and a full
93
+ rediscovery handles the rest.
94
+ """
95
+ win = ax.window(ax_app, cfg["window_prefix"])
96
+ if win is None:
97
+ raise NotFound(f"no window titled {cfg['window_prefix']!r}")
98
+ origin = ax.position(win)
99
+
100
+ if cfg.get("symbol_dx") is not None:
101
+ x, y = int(origin.x) + cfg["symbol_dx"], int(origin.y) + cfg["symbol_dy"]
102
+ ok, info = validate(ax.element_at(ax_app, x, y))
103
+ if ok:
104
+ return ax.element_at(ax_app, x, y), info
105
+ for r in (4, 8, 12):
106
+ for dx, dy in ((r, 0), (-r, 0), (0, r), (0, -r), (r, r), (-r, -r)):
107
+ ok, info = validate(ax.element_at(ax_app, x + dx, y + dy))
108
+ if ok:
109
+ return ax.element_at(ax_app, x + dx, y + dy), info
110
+
111
+ found = discover(ax_app, cfg["window_prefix"])
112
+ if found is None:
113
+ raise NotFound("symbol field did not validate and could not be rediscovered")
114
+ config.update(symbol_dx=int(found[0] - origin.x),
115
+ symbol_dy=int(found[1] - origin.y))
116
+ element = ax.element_at(ax_app, *found)
117
+ return element, validate(element)[1]
118
+
119
+
120
+ def current(app=None) -> str:
121
+ """What the field reports.
122
+
123
+ Note this can be STALE: thinkorswim does not push accessibility updates,
124
+ so after a change it may still report the previous symbol. Use it to
125
+ confirm the field is reachable, never to confirm a change took.
126
+ """
127
+ app = app or ax.running_app()
128
+ ax.activate(app)
129
+ _, value = locate(ax.handle(app), config.load())
130
+ return value
131
+
132
+
133
+ def show(ticker: str, app=None) -> str:
134
+ """Put a ticker in the chart. Returns the symbol that was displaced."""
135
+ ticker = to_ticker(ticker)
136
+ if not ticker or not TICKER.match(ticker):
137
+ raise ValueError(f"not a valid ticker: {ticker!r}")
138
+
139
+ app = app or ax.running_app()
140
+ pid = app.processIdentifier()
141
+ ax.activate(app)
142
+ field, previous = locate(ax.handle(app), config.load())
143
+
144
+ if not ax.set_attr(field, "AXFocused", True):
145
+ raise NotFound("could not focus the symbol field")
146
+ time.sleep(0.15)
147
+
148
+ ax.key(ax.KEYCODE["a"], Quartz.kCGEventFlagMaskCommand, pid)
149
+ time.sleep(0.10)
150
+ ax.type_text(ticker, pid)
151
+
152
+ # thinkorswim pops an autocomplete list as you type. A Return arriving
153
+ # before it renders is swallowed and the list stays open over the chart.
154
+ # Let it settle, accept it, then commit - the second Return is a no-op
155
+ # on an already-committed field.
156
+ time.sleep(0.35)
157
+ ax.key(ax.KEY_RETURN, 0, pid)
158
+ time.sleep(0.25)
159
+ ax.key(ax.KEY_RETURN, 0, pid)
160
+ return previous
@@ -0,0 +1,51 @@
1
+ """This Mac's place on the patron's tailnet.
2
+
3
+ The voice Shortcut reaches the agent at an HTTPS address that exists only
4
+ inside the patron's Tailscale network. ``tailscale serve`` gives the Mac that
5
+ address and forwards ``/chart`` to the agent's loopback listener.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import shutil
12
+ import subprocess
13
+
14
+ DOWNLOAD = "https://tailscale.com/download/mac"
15
+ APP_CLI = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
16
+
17
+
18
+ class TailnetError(RuntimeError):
19
+ """Tailscale is missing, signed out, or refused."""
20
+
21
+
22
+ def cli() -> str:
23
+ found = shutil.which("tailscale")
24
+ if found:
25
+ return found
26
+ if shutil.which(APP_CLI) or subprocess.run(["test", "-x", APP_CLI], check=False).returncode == 0:
27
+ return APP_CLI
28
+ raise TailnetError(f"Tailscale is not installed. Install it from {DOWNLOAD}, sign in, then run setup again.")
29
+
30
+
31
+ def status() -> dict:
32
+ ran = subprocess.run([cli(), "status", "--json"], capture_output=True, text=True, check=False)
33
+ if ran.returncode != 0:
34
+ raise TailnetError("Tailscale is not running or not signed in. Open Tailscale and sign in.")
35
+ return json.loads(ran.stdout or "{}")
36
+
37
+
38
+ def chart_url(state: dict) -> str:
39
+ """The Shortcut's address: this Mac's tailnet name plus ``/chart``."""
40
+ name = ((state.get("Self") or {}).get("DNSName") or "").rstrip(".")
41
+ if not name:
42
+ raise TailnetError("Tailscale reports no name for this Mac. Is MagicDNS on?")
43
+ return f"https://{name}/chart"
44
+
45
+
46
+ def serve(port: int) -> None:
47
+ """Forward ``/chart`` on the tailnet address to the agent. Persistent and tailnet-only."""
48
+ ran = subprocess.run([cli(), "serve", "--bg", "--set-path", "/chart", f"http://127.0.0.1:{port}"],
49
+ capture_output=True, text=True, check=False)
50
+ if ran.returncode != 0:
51
+ raise TailnetError(f"tailscale serve refused: {(ran.stderr or ran.stdout).strip()[:200]}")