chartremotely 0.2.9__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.9 → chartremotely-0.2.10}/PKG-INFO +1 -1
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/ax.py +19 -5
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/requestlog.py +9 -0
- chartremotely-0.2.10/chartremotely/screen.py +116 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/vocab.py +25 -7
- chartremotely-0.2.10/chartremotely/window.py +166 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely.egg-info/PKG-INFO +1 -1
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely.egg-info/SOURCES.txt +2 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/pyproject.toml +1 -1
- chartremotely-0.2.10/tests/test_screen.py +77 -0
- chartremotely-0.2.9/chartremotely/window.py +0 -134
- {chartremotely-0.2.9 → chartremotely-0.2.10}/LICENSE +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/README.md +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/__init__.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/answer.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/assets/shortcut-template.plist +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/cli.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/config.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/doctor.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/guilock.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/keystore.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/layout.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/mcpclient.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/patron.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/push.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/registry.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/relay.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/resolve.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/scales.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/server.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/services.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/setup.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/shortcut.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/snapshot.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/studies.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/symbol.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/tailnet.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely/timeframe.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely.egg-info/dependency_links.txt +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely.egg-info/entry_points.txt +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely.egg-info/requires.txt +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/chartremotely.egg-info/top_level.txt +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/setup.cfg +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/tests/test_patron.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/tests/test_push.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/tests/test_relay.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/tests/test_requestlog.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/tests/test_resolve.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/tests/test_setup.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/tests/test_snapshot.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/tests/test_stamp_changelog.py +0 -0
- {chartremotely-0.2.9 → chartremotely-0.2.10}/tests/test_timeframe.py +0 -0
|
@@ -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):
|
|
@@ -51,3 +51,12 @@ def outcome(source: str, tool: str, where: str, error: str | None) -> None:
|
|
|
51
51
|
print(" ".join(parts), file=sys.stderr, flush=True)
|
|
52
52
|
except (OSError, ValueError):
|
|
53
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"
|
|
@@ -16,7 +16,7 @@ Two rules the callers depend on:
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
-
from . import guilock, registry, resolve, scales, snapshot, symbol, timeframe, window
|
|
19
|
+
from . import guilock, registry, requestlog, resolve, scales, snapshot, symbol, timeframe, window
|
|
20
20
|
from .answer import Answer
|
|
21
21
|
|
|
22
22
|
ERR = "ERR "
|
|
@@ -26,6 +26,21 @@ def _short(text: str, limit: int = 120) -> str:
|
|
|
26
26
|
return str(text).splitlines()[0][:limit] if text else "something went wrong"
|
|
27
27
|
|
|
28
28
|
|
|
29
|
+
def _present() -> str | None:
|
|
30
|
+
"""Get the chart in front of the viewer before driving it; see window.present.
|
|
31
|
+
|
|
32
|
+
Returns an ERR reply when it cannot be, else None. What it took (unhid,
|
|
33
|
+
raised, which apps it hid) goes to the log, never into the spoken reply.
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
done = window.present()
|
|
37
|
+
except Exception as exc:
|
|
38
|
+
return ERR + _short(exc)
|
|
39
|
+
if done:
|
|
40
|
+
requestlog.note("present", str(done))
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
29
44
|
def cmd_resolve(spoken: str) -> str:
|
|
30
45
|
"""A spoken company name, to a ticker."""
|
|
31
46
|
if not spoken.strip():
|
|
@@ -63,6 +78,9 @@ def set_chart(ticker: str, scale: str = "") -> Answer:
|
|
|
63
78
|
ticker = ticker.strip().replace(" ", "")
|
|
64
79
|
if not ticker:
|
|
65
80
|
return Answer(ERR + "No symbol to show.")
|
|
81
|
+
refused = _present()
|
|
82
|
+
if refused:
|
|
83
|
+
return Answer(refused)
|
|
66
84
|
try:
|
|
67
85
|
symbol.show(ticker)
|
|
68
86
|
except Exception as exc:
|
|
@@ -76,12 +94,6 @@ def set_chart(ticker: str, scale: str = "") -> Answer:
|
|
|
76
94
|
except Exception:
|
|
77
95
|
word = None
|
|
78
96
|
|
|
79
|
-
# Uncover the chart: a voice answer is only useful if the screen shows it.
|
|
80
|
-
try:
|
|
81
|
-
window.clear()
|
|
82
|
-
except Exception:
|
|
83
|
-
pass
|
|
84
|
-
|
|
85
97
|
if word:
|
|
86
98
|
return Answer(f"Showing {ticker} at {word}. Good luck.", shown, word)
|
|
87
99
|
try:
|
|
@@ -93,6 +105,9 @@ def set_chart(ticker: str, scale: str = "") -> Answer:
|
|
|
93
105
|
|
|
94
106
|
def cmd_read() -> str:
|
|
95
107
|
"""Report the chart's current symbol and scale."""
|
|
108
|
+
refused = _present()
|
|
109
|
+
if refused:
|
|
110
|
+
return refused
|
|
96
111
|
try:
|
|
97
112
|
ticker = symbol.current()
|
|
98
113
|
_, word = timeframe.current()
|
|
@@ -103,6 +118,9 @@ def cmd_read() -> str:
|
|
|
103
118
|
|
|
104
119
|
def cmd_snapshot() -> str:
|
|
105
120
|
"""A picture of the chart pane, so a caller far away can see it."""
|
|
121
|
+
refused = _present()
|
|
122
|
+
if refused:
|
|
123
|
+
return refused
|
|
106
124
|
try:
|
|
107
125
|
return snapshot.as_reply(snapshot.shrink(window.capture()))
|
|
108
126
|
except Exception as exc:
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Make the chart visible, and prove what it shows.
|
|
2
|
+
|
|
3
|
+
A voice command answered from across the room is only useful if the chart
|
|
4
|
+
is actually on screen, and only trustworthy if the caller can see that it
|
|
5
|
+
changed. Both matter more remotely than locally: at the desk you can see it
|
|
6
|
+
worked; from another building a silent failure is indistinguishable from
|
|
7
|
+
success.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import Quartz
|
|
18
|
+
|
|
19
|
+
from . import ax, config, layout, screen, snapshot, symbol
|
|
20
|
+
|
|
21
|
+
#: NSApplicationActivateAllWindows | NSApplicationActivateIgnoringOtherApps:
|
|
22
|
+
#: bring every thinkorswim window forward, even when another app is in front.
|
|
23
|
+
ACTIVATE = 1 | 2
|
|
24
|
+
#: Longest wait for the chart window to reach the screen after being raised.
|
|
25
|
+
ON_SCREEN_WAIT = 2.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NotOnScreen(RuntimeError):
|
|
29
|
+
"""The chart window could not be brought on screen."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _on_screen() -> list[dict]:
|
|
33
|
+
return list(Quartz.CGWindowListCopyWindowInfo(
|
|
34
|
+
Quartz.kCGWindowListOptionOnScreenOnly, Quartz.kCGNullWindowID) or [])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _frame(pid: int) -> dict | None:
|
|
38
|
+
"""The largest on-screen thinkorswim window."""
|
|
39
|
+
return screen.largest(_on_screen(), pid)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _ax_frame(win) -> screen.Frame | None:
|
|
43
|
+
p, s = ax.position(win), ax.size(win)
|
|
44
|
+
return (p.x, p.y, s.width, s.height) if p and s else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def present(app=None) -> screen.Presented:
|
|
48
|
+
"""Put the chart window in front of the viewer, before anything drives it.
|
|
49
|
+
|
|
50
|
+
Unhides thinkorswim, brings it forward, un-minimises and raises the chart
|
|
51
|
+
window (the one titled ``window_prefix``, which every driver works in),
|
|
52
|
+
waits until it is actually on screen - activation also switches to its
|
|
53
|
+
Space when it is full screen elsewhere - then hides the ordinary apps
|
|
54
|
+
overlapping it. Only regular apps are ever hidden: system overlays keep
|
|
55
|
+
permanent full-screen windows and are neither hideable nor in the way.
|
|
56
|
+
|
|
57
|
+
Run inside the chart lock, like every command that drives the chart.
|
|
58
|
+
"""
|
|
59
|
+
app = app or ax.running_app()
|
|
60
|
+
pid = app.processIdentifier()
|
|
61
|
+
ax_app = ax.handle(app)
|
|
62
|
+
# Read from the process itself: NSRunningApplication's isHidden/isActive
|
|
63
|
+
# only refresh on a run loop, which the listener and relay never spin.
|
|
64
|
+
unhid = bool(ax.attr(ax_app, "AXHidden"))
|
|
65
|
+
raised = not ax.attr(ax_app, "AXFrontmost")
|
|
66
|
+
if unhid:
|
|
67
|
+
app.unhide()
|
|
68
|
+
app.activateWithOptions_(ACTIVATE)
|
|
69
|
+
|
|
70
|
+
prefix = config.load()["window_prefix"]
|
|
71
|
+
win = screen.wait_for(lambda: ax.window(ax_app, prefix), timeout=ON_SCREEN_WAIT)
|
|
72
|
+
if win is None:
|
|
73
|
+
raise NotOnScreen(f"no thinkorswim window titled {prefix!r}")
|
|
74
|
+
unminimized = bool(ax.attr(win, "AXMinimized"))
|
|
75
|
+
if unminimized:
|
|
76
|
+
ax.set_attr(win, "AXMinimized", False)
|
|
77
|
+
ax.perform(win, "AXRaise")
|
|
78
|
+
ax.set_attr(win, "AXMain", True)
|
|
79
|
+
ax.set_attr(win, "AXFocused", True)
|
|
80
|
+
|
|
81
|
+
def arrived():
|
|
82
|
+
frame = _ax_frame(win)
|
|
83
|
+
return frame and screen.showing(_on_screen(), pid, frame)
|
|
84
|
+
|
|
85
|
+
target = screen.wait_for(arrived, timeout=ON_SCREEN_WAIT)
|
|
86
|
+
if target is None:
|
|
87
|
+
raise NotOnScreen("the chart window did not come on screen")
|
|
88
|
+
|
|
89
|
+
hid = _uncover(pid, target["kCGWindowBounds"])
|
|
90
|
+
app.activateWithOptions_(ACTIVATE)
|
|
91
|
+
return screen.Presented(unhid=unhid, unminimized=unminimized,
|
|
92
|
+
raised=raised or unminimized, hid=hid)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _uncover(pid: int, bounds: dict) -> tuple[str, ...]:
|
|
96
|
+
"""Hide every ordinary app with a window over ``bounds``; their names."""
|
|
97
|
+
windows = _on_screen()
|
|
98
|
+
apps = {p: ax.app_for(p) for p in {w.get("kCGWindowOwnerPID") for w in windows} if p}
|
|
99
|
+
regular = {p: a for p, a in apps.items() if a is not None and a.activationPolicy() == 0}
|
|
100
|
+
hidden = []
|
|
101
|
+
# An app with a window on screen is not hidden, whatever a cached
|
|
102
|
+
# isHidden says; see present().
|
|
103
|
+
for p in screen.covering(windows, bounds, pid, regular):
|
|
104
|
+
a = regular[p]
|
|
105
|
+
a.hide()
|
|
106
|
+
hidden.append(a.localizedName() or str(p))
|
|
107
|
+
if hidden:
|
|
108
|
+
time.sleep(0.4)
|
|
109
|
+
return tuple(sorted(hidden))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def capture(app=None) -> bytes:
|
|
113
|
+
"""PNG of the chart's own pane, and nothing else in the window.
|
|
114
|
+
|
|
115
|
+
Captured by window id rather than screen region, so it works even when
|
|
116
|
+
something is floating on top - which is what makes it usable as proof
|
|
117
|
+
that a remote command landed. Then cropped to the pane that holds the
|
|
118
|
+
chart's symbol field; see :func:`snapshot.chart_crop` for why the rest of
|
|
119
|
+
the window never leaves this machine.
|
|
120
|
+
"""
|
|
121
|
+
app = app or ax.running_app()
|
|
122
|
+
target = _frame(app.processIdentifier())
|
|
123
|
+
if target is None:
|
|
124
|
+
raise ax.NotRunning("no thinkorswim window on screen")
|
|
125
|
+
b = target["kCGWindowBounds"]
|
|
126
|
+
bounds = (int(b["X"]), int(b["Y"]), int(b["Width"]), int(b["Height"]))
|
|
127
|
+
|
|
128
|
+
# Hit-testing answers from the menu bar unless the app is in front.
|
|
129
|
+
ax.activate(app)
|
|
130
|
+
ax_app = ax.handle(app)
|
|
131
|
+
prefix = config.load()["window_prefix"]
|
|
132
|
+
hit = symbol.discover(ax_app, prefix)
|
|
133
|
+
if hit is None:
|
|
134
|
+
raise snapshot.ChartNotIsolated("no chart is visible")
|
|
135
|
+
panes = layout.panes(ax_app, prefix)
|
|
136
|
+
|
|
137
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
138
|
+
shot = Path(tmp) / "window.png"
|
|
139
|
+
subprocess.run(["screencapture", "-x", "-o", "-l", str(target["kCGWindowNumber"]), str(shot)],
|
|
140
|
+
check=True, capture_output=True)
|
|
141
|
+
crop = snapshot.chart_crop(hit, panes, bounds, _pixel_size(shot))
|
|
142
|
+
chart = Path(tmp) / "chart.png"
|
|
143
|
+
_crop(shot, chart, *crop)
|
|
144
|
+
return chart.read_bytes()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _image(path: Path):
|
|
148
|
+
from CoreFoundation import CFURLCreateWithFileSystemPath, kCFURLPOSIXPathStyle
|
|
149
|
+
|
|
150
|
+
url = CFURLCreateWithFileSystemPath(None, str(path), kCFURLPOSIXPathStyle, False)
|
|
151
|
+
return Quartz.CGImageSourceCreateImageAtIndex(Quartz.CGImageSourceCreateWithURL(url, None), 0, None)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _pixel_size(path: Path) -> tuple[int, int]:
|
|
155
|
+
image = _image(path)
|
|
156
|
+
return Quartz.CGImageGetWidth(image), Quartz.CGImageGetHeight(image)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _crop(src: Path, dst: Path, x: int, y: int, w: int, h: int) -> None:
|
|
160
|
+
from CoreFoundation import CFURLCreateWithFileSystemPath, kCFURLPOSIXPathStyle
|
|
161
|
+
|
|
162
|
+
region = Quartz.CGImageCreateWithImageInRect(_image(src), Quartz.CGRectMake(x, y, w, h))
|
|
163
|
+
out_url = CFURLCreateWithFileSystemPath(None, str(dst), kCFURLPOSIXPathStyle, False)
|
|
164
|
+
dest = Quartz.CGImageDestinationCreateWithURL(out_url, "public.png", 1, None)
|
|
165
|
+
Quartz.CGImageDestinationAddImage(dest, region, None)
|
|
166
|
+
Quartz.CGImageDestinationFinalize(dest)
|
|
@@ -18,6 +18,7 @@ chartremotely/relay.py
|
|
|
18
18
|
chartremotely/requestlog.py
|
|
19
19
|
chartremotely/resolve.py
|
|
20
20
|
chartremotely/scales.py
|
|
21
|
+
chartremotely/screen.py
|
|
21
22
|
chartremotely/server.py
|
|
22
23
|
chartremotely/services.py
|
|
23
24
|
chartremotely/setup.py
|
|
@@ -41,6 +42,7 @@ tests/test_push.py
|
|
|
41
42
|
tests/test_relay.py
|
|
42
43
|
tests/test_requestlog.py
|
|
43
44
|
tests/test_resolve.py
|
|
45
|
+
tests/test_screen.py
|
|
44
46
|
tests/test_setup.py
|
|
45
47
|
tests/test_snapshot.py
|
|
46
48
|
tests/test_stamp_changelog.py
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""The pure half of bringing the chart in front: which window, whether it is back, what covers it."""
|
|
2
|
+
|
|
3
|
+
from chartremotely import screen
|
|
4
|
+
|
|
5
|
+
TOS, SAFARI, MAIL, OVERLAY = 10, 20, 30, 40
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def win(pid, x, y, w, h, layer=0):
|
|
9
|
+
return {"kCGWindowOwnerPID": pid, "kCGWindowLayer": layer,
|
|
10
|
+
"kCGWindowBounds": {"X": x, "Y": y, "Width": w, "Height": h}}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
CHART = win(TOS, 0, 25, 1600, 1000)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_overlap_needs_shared_area_not_a_shared_edge():
|
|
17
|
+
a = {"X": 0, "Y": 0, "Width": 100, "Height": 100}
|
|
18
|
+
assert screen.overlaps(a, {"X": 50, "Y": 50, "Width": 100, "Height": 100})
|
|
19
|
+
assert not screen.overlaps(a, {"X": 100, "Y": 0, "Width": 50, "Height": 50})
|
|
20
|
+
assert not screen.overlaps(a, {"X": 0, "Y": 100, "Width": 50, "Height": 50})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_largest_takes_only_the_apps_own_windows():
|
|
24
|
+
windows = [win(SAFARI, 0, 0, 3000, 3000), win(TOS, 0, 0, 200, 200), CHART]
|
|
25
|
+
assert screen.largest(windows, TOS) is CHART
|
|
26
|
+
assert screen.largest(windows, MAIL) is None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_the_chart_is_showing_only_when_its_own_frame_is_on_screen():
|
|
30
|
+
detached = win(TOS, 1700, 0, 400, 300)
|
|
31
|
+
assert screen.showing([detached], TOS, (0, 25, 1600, 1000)) is None
|
|
32
|
+
assert screen.showing([detached, CHART], TOS, (0, 25, 1600, 1000)) is CHART
|
|
33
|
+
# A point of rounding between the two APIs is not a different window.
|
|
34
|
+
assert screen.showing([CHART], TOS, (1, 24, 1600, 1001)) is CHART
|
|
35
|
+
assert screen.showing([CHART], SAFARI, (0, 25, 1600, 1000)) is None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_covering_names_each_overlapping_ordinary_app_once():
|
|
39
|
+
windows = [win(SAFARI, 100, 100, 500, 500), win(SAFARI, 200, 200, 50, 50),
|
|
40
|
+
win(MAIL, 2000, 0, 300, 300), CHART]
|
|
41
|
+
assert screen.covering(windows, CHART["kCGWindowBounds"], TOS,
|
|
42
|
+
{SAFARI, MAIL}) == [SAFARI]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_covering_never_touches_overlays_the_desktop_or_the_chart_itself():
|
|
46
|
+
windows = [win(OVERLAY, 0, 0, 3000, 2000, layer=25), # not a regular app
|
|
47
|
+
win(SAFARI, 0, 0, 3000, 2000, layer=-2147483603), # desktop layer
|
|
48
|
+
CHART]
|
|
49
|
+
assert screen.covering(windows, CHART["kCGWindowBounds"], TOS,
|
|
50
|
+
{SAFARI, TOS}) == []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_wait_for_returns_the_first_truthy_probe():
|
|
54
|
+
answers = iter([None, None, "here"])
|
|
55
|
+
t = [0.0]
|
|
56
|
+
got = screen.wait_for(lambda: next(answers), timeout=2.0, interval=0.1,
|
|
57
|
+
clock=lambda: t[0], sleep=lambda s: t.__setitem__(0, t[0] + s))
|
|
58
|
+
assert got == "here"
|
|
59
|
+
assert abs(t[0] - 0.2) < 1e-9
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_wait_for_gives_up_after_the_timeout_having_probed_at_least_once():
|
|
63
|
+
probes = []
|
|
64
|
+
t = [0.0]
|
|
65
|
+
got = screen.wait_for(lambda: probes.append(1), timeout=0.5, interval=0.1,
|
|
66
|
+
clock=lambda: t[0], sleep=lambda s: t.__setitem__(0, t[0] + s))
|
|
67
|
+
assert got is None
|
|
68
|
+
assert 5 <= len(probes) <= 7
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_presented_says_what_it_took():
|
|
72
|
+
assert not screen.Presented()
|
|
73
|
+
assert str(screen.Presented()) == "already in front"
|
|
74
|
+
done = screen.Presented(unhid=True, raised=True, hid=("Mail", "Safari"))
|
|
75
|
+
assert done
|
|
76
|
+
assert str(done) == "unhid; raised; hid Mail, Safari"
|
|
77
|
+
assert str(screen.Presented(unminimized=True, raised=True)) == "unminimized; raised"
|
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
"""Make the chart visible, and prove what it shows.
|
|
2
|
-
|
|
3
|
-
A voice command answered from across the room is only useful if the chart
|
|
4
|
-
is actually on screen, and only trustworthy if the caller can see that it
|
|
5
|
-
changed. Both matter more remotely than locally: at the desk you can see it
|
|
6
|
-
worked; from another building a silent failure is indistinguishable from
|
|
7
|
-
success.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import subprocess
|
|
13
|
-
import tempfile
|
|
14
|
-
import time
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
|
|
17
|
-
import Quartz
|
|
18
|
-
from AppKit import NSWorkspace
|
|
19
|
-
|
|
20
|
-
from . import ax, config, layout, snapshot, symbol
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def _frame(pid: int) -> dict | None:
|
|
24
|
-
"""Bounds of the largest on-screen thinkorswim window."""
|
|
25
|
-
best, best_area = None, -1
|
|
26
|
-
for w in Quartz.CGWindowListCopyWindowInfo(
|
|
27
|
-
Quartz.kCGWindowListOptionOnScreenOnly, Quartz.kCGNullWindowID):
|
|
28
|
-
if w.get("kCGWindowOwnerPID") != pid:
|
|
29
|
-
continue
|
|
30
|
-
b = w.get("kCGWindowBounds") or {}
|
|
31
|
-
area = b.get("Width", 0) * b.get("Height", 0)
|
|
32
|
-
if area > best_area:
|
|
33
|
-
best, best_area = w, area
|
|
34
|
-
return best
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _overlaps(a: dict, b: dict) -> bool:
|
|
38
|
-
return not (a.get("X", 0) + a.get("Width", 0) <= b.get("X", 0)
|
|
39
|
-
or a.get("X", 0) >= b.get("X", 0) + b.get("Width", 0)
|
|
40
|
-
or a.get("Y", 0) + a.get("Height", 0) <= b.get("Y", 0)
|
|
41
|
-
or a.get("Y", 0) >= b.get("Y", 0) + b.get("Height", 0))
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def clear(app=None) -> list[str]:
|
|
45
|
-
"""Hide every ordinary app overlapping the chart, then raise it.
|
|
46
|
-
|
|
47
|
-
Only regular apps are touched - system overlays keep permanent
|
|
48
|
-
full-screen windows and are neither hideable nor actually in the way.
|
|
49
|
-
"""
|
|
50
|
-
app = app or ax.running_app()
|
|
51
|
-
pid = app.processIdentifier()
|
|
52
|
-
target = _frame(pid)
|
|
53
|
-
if target is None:
|
|
54
|
-
return []
|
|
55
|
-
bounds = target["kCGWindowBounds"]
|
|
56
|
-
|
|
57
|
-
regular = {a.processIdentifier(): a
|
|
58
|
-
for a in NSWorkspace.sharedWorkspace().runningApplications()
|
|
59
|
-
if a.activationPolicy() == 0 and a.processIdentifier() != pid}
|
|
60
|
-
|
|
61
|
-
covering = {w["kCGWindowOwnerPID"]
|
|
62
|
-
for w in Quartz.CGWindowListCopyWindowInfo(
|
|
63
|
-
Quartz.kCGWindowListOptionOnScreenOnly, Quartz.kCGNullWindowID)
|
|
64
|
-
if w.get("kCGWindowOwnerPID") in regular
|
|
65
|
-
and w.get("kCGWindowLayer", 0) >= 0
|
|
66
|
-
and _overlaps(w.get("kCGWindowBounds") or {}, bounds)}
|
|
67
|
-
|
|
68
|
-
hidden = []
|
|
69
|
-
for p in covering:
|
|
70
|
-
a = regular[p]
|
|
71
|
-
if not a.isHidden():
|
|
72
|
-
a.hide()
|
|
73
|
-
hidden.append(a.localizedName() or str(p))
|
|
74
|
-
if hidden:
|
|
75
|
-
time.sleep(0.4)
|
|
76
|
-
app.activateWithOptions_(2)
|
|
77
|
-
return sorted(hidden)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def capture(app=None) -> bytes:
|
|
81
|
-
"""PNG of the chart's own pane, and nothing else in the window.
|
|
82
|
-
|
|
83
|
-
Captured by window id rather than screen region, so it works even when
|
|
84
|
-
something is floating on top - which is what makes it usable as proof
|
|
85
|
-
that a remote command landed. Then cropped to the pane that holds the
|
|
86
|
-
chart's symbol field; see :func:`snapshot.chart_crop` for why the rest of
|
|
87
|
-
the window never leaves this machine.
|
|
88
|
-
"""
|
|
89
|
-
app = app or ax.running_app()
|
|
90
|
-
target = _frame(app.processIdentifier())
|
|
91
|
-
if target is None:
|
|
92
|
-
raise ax.NotRunning("no thinkorswim window on screen")
|
|
93
|
-
b = target["kCGWindowBounds"]
|
|
94
|
-
bounds = (int(b["X"]), int(b["Y"]), int(b["Width"]), int(b["Height"]))
|
|
95
|
-
|
|
96
|
-
# Hit-testing answers from the menu bar unless the app is in front.
|
|
97
|
-
ax.activate(app)
|
|
98
|
-
ax_app = ax.handle(app)
|
|
99
|
-
prefix = config.load()["window_prefix"]
|
|
100
|
-
hit = symbol.discover(ax_app, prefix)
|
|
101
|
-
if hit is None:
|
|
102
|
-
raise snapshot.ChartNotIsolated("no chart is visible")
|
|
103
|
-
panes = layout.panes(ax_app, prefix)
|
|
104
|
-
|
|
105
|
-
with tempfile.TemporaryDirectory() as tmp:
|
|
106
|
-
shot = Path(tmp) / "window.png"
|
|
107
|
-
subprocess.run(["screencapture", "-x", "-o", "-l", str(target["kCGWindowNumber"]), str(shot)],
|
|
108
|
-
check=True, capture_output=True)
|
|
109
|
-
crop = snapshot.chart_crop(hit, panes, bounds, _pixel_size(shot))
|
|
110
|
-
chart = Path(tmp) / "chart.png"
|
|
111
|
-
_crop(shot, chart, *crop)
|
|
112
|
-
return chart.read_bytes()
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def _image(path: Path):
|
|
116
|
-
from CoreFoundation import CFURLCreateWithFileSystemPath, kCFURLPOSIXPathStyle
|
|
117
|
-
|
|
118
|
-
url = CFURLCreateWithFileSystemPath(None, str(path), kCFURLPOSIXPathStyle, False)
|
|
119
|
-
return Quartz.CGImageSourceCreateImageAtIndex(Quartz.CGImageSourceCreateWithURL(url, None), 0, None)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def _pixel_size(path: Path) -> tuple[int, int]:
|
|
123
|
-
image = _image(path)
|
|
124
|
-
return Quartz.CGImageGetWidth(image), Quartz.CGImageGetHeight(image)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def _crop(src: Path, dst: Path, x: int, y: int, w: int, h: int) -> None:
|
|
128
|
-
from CoreFoundation import CFURLCreateWithFileSystemPath, kCFURLPOSIXPathStyle
|
|
129
|
-
|
|
130
|
-
region = Quartz.CGImageCreateWithImageInRect(_image(src), Quartz.CGRectMake(x, y, w, h))
|
|
131
|
-
out_url = CFURLCreateWithFileSystemPath(None, str(dst), kCFURLPOSIXPathStyle, False)
|
|
132
|
-
dest = Quartz.CGImageDestinationCreateWithURL(out_url, "public.png", 1, None)
|
|
133
|
-
Quartz.CGImageDestinationAddImage(dest, region, None)
|
|
134
|
-
Quartz.CGImageDestinationFinalize(dest)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|