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/resolve.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Turn a spoken security name into a ticker.
|
|
2
|
+
|
|
3
|
+
Speech recognition is the weak link in voice-driven charting. Dictation
|
|
4
|
+
matches against the whole English language, so "Palantir" arrives as
|
|
5
|
+
"Volunteer" and "Qualcomm" as "Callalon". This module repairs what it can
|
|
6
|
+
without ever inventing an answer: every tier is gated so that noise is
|
|
7
|
+
refused rather than mapped confidently onto some unrelated company.
|
|
8
|
+
|
|
9
|
+
Matching runs in tiers, highest score wins:
|
|
10
|
+
|
|
11
|
+
1000 the query IS a ticker
|
|
12
|
+
900 exact company name
|
|
13
|
+
800 the name starts with the query
|
|
14
|
+
700 every spoken word appears in the name
|
|
15
|
+
650 the query COVERS the name, plus extra words ("john deere")
|
|
16
|
+
600 consonant skeletons match ("volunteer")
|
|
17
|
+
400 fuzzy, as a last resort
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import difflib
|
|
23
|
+
import re
|
|
24
|
+
|
|
25
|
+
__all__ = ["candidates", "normalize", "phonetic", "resolve", "spelled"]
|
|
26
|
+
|
|
27
|
+
# Corporate furniture, stripped from both sides before comparison. "and"
|
|
28
|
+
# is here because "&" normalises to it, so DEERE & CO reduces to "deere".
|
|
29
|
+
SUFFIXES = {
|
|
30
|
+
"inc", "incorporated", "corp", "corporation", "co", "company", "ltd",
|
|
31
|
+
"limited", "plc", "lp", "llc", "holdings", "holding", "group", "trust",
|
|
32
|
+
"the", "sa", "nv", "ag", "adr", "ads", "class", "a", "b", "c", "etf",
|
|
33
|
+
"fund", "common", "stock", "shares", "new", "series", "and",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# Filler, pronouns and contraction fragments. Without these the phonetic
|
|
37
|
+
# tier maps conversation onto companies: "whatever" codes like DOV, and
|
|
38
|
+
# "i don t" codes exactly like "at and t".
|
|
39
|
+
STOPWORDS = {
|
|
40
|
+
"the", "a", "an", "and", "um", "uh", "er", "hey", "hi", "ok", "okay",
|
|
41
|
+
"what", "who", "how", "why", "when", "yes", "no", "please", "thanks",
|
|
42
|
+
"siri", "show", "me", "go", "to", "open", "chart", "stock", "symbol",
|
|
43
|
+
"it", "that", "this", "is", "whatever", "something", "nothing",
|
|
44
|
+
"anything", "everything", "nevermind", "never", "mind", "forget",
|
|
45
|
+
"cancel", "stop", "quit", "wait", "hold", "on", "hmm", "hello",
|
|
46
|
+
"sorry", "maybe", "dunno", "know", "guess", "again", "i", "you", "we",
|
|
47
|
+
"my", "your", "don", "dont", "doesn", "doesnt", "can", "cant", "wont",
|
|
48
|
+
"im", "ive", "id", "let", "just", "well", "yeah", "yep", "nope", "like",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Transcripts that discard the consonant skeleton entirely, so no algorithm
|
|
52
|
+
# recovers them, plus renames the SEC registry has not caught up with.
|
|
53
|
+
# Grow this only from transcripts actually observed in the wild.
|
|
54
|
+
ALIASES = {
|
|
55
|
+
"kuehn": "COIN", # nasal "Coin"
|
|
56
|
+
"koon": "COIN",
|
|
57
|
+
"pierre": "PLTR", # Siri's web-search guess for "Palantir"
|
|
58
|
+
"callalon": "QCOM", # nasal "Qualcomm"
|
|
59
|
+
"ge aerospace": "GE", # still filed as GENERAL ELECTRIC CO
|
|
60
|
+
"ge aviation": "GE",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# Soundex-style consonant coding: vowels carry almost no information in a
|
|
64
|
+
# mis-hearing, consonants carry nearly all of it.
|
|
65
|
+
_SOUNDEX = {
|
|
66
|
+
**dict.fromkeys("bfpv", "1"), **dict.fromkeys("cgjkqsxz", "2"),
|
|
67
|
+
**dict.fromkeys("dt", "3"), "l": "4",
|
|
68
|
+
**dict.fromkeys("mn", "5"), "r": "6",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
LETTER_WORDS = {
|
|
72
|
+
"ay": "a", "eh": "a", "bee": "b", "be": "b", "see": "c", "sea": "c",
|
|
73
|
+
"dee": "d", "de": "d", "ee": "e", "eff": "f", "ef": "f", "gee": "g",
|
|
74
|
+
"aitch": "h", "haitch": "h", "eye": "i", "aye": "i", "jay": "j",
|
|
75
|
+
"kay": "k", "el": "l", "ell": "l", "em": "m", "en": "n", "oh": "o",
|
|
76
|
+
"owe": "o", "pee": "p", "pea": "p", "cue": "q", "queue": "q", "ar": "r",
|
|
77
|
+
"are": "r", "arr": "r", "ess": "s", "es": "s", "tee": "t", "tea": "t",
|
|
78
|
+
"you": "u", "yoo": "u", "vee": "v", "doubleyou": "w", "ex": "x",
|
|
79
|
+
"ecks": "x", "why": "y", "wye": "y", "zee": "z", "zed": "z",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
MIN_PHONETIC = 3 # shorter skeletons collide with everything
|
|
83
|
+
PHONETIC_MIN_RATIO = 0.45 # a skeleton match must also look plausible
|
|
84
|
+
MIN_PREFIX = 4
|
|
85
|
+
MIN_FUZZY = 5
|
|
86
|
+
FUZZY_FLOOR = 0.82
|
|
87
|
+
NAME_COVERAGE = 0.6 # how much of the company name the query must cover
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def normalize(text: str) -> str:
|
|
91
|
+
t = text.lower().replace("&", " and ")
|
|
92
|
+
return " ".join(re.sub(r"[^a-z0-9 ]+", " ", t).split())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def strip_suffixes(norm: str) -> str:
|
|
96
|
+
return " ".join(w for w in norm.split() if w not in SUFFIXES) or norm
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def phonetic(text: str) -> str:
|
|
100
|
+
"""Consonant skeleton. 'palantir' and 'volunteer' both give '14536'."""
|
|
101
|
+
out: list[str] = []
|
|
102
|
+
last = ""
|
|
103
|
+
for ch in text.lower():
|
|
104
|
+
code = _SOUNDEX.get(ch)
|
|
105
|
+
if code and code != last:
|
|
106
|
+
out.append(code)
|
|
107
|
+
if ch.isalpha():
|
|
108
|
+
last = code or ""
|
|
109
|
+
return "".join(out)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def deinflect(word: str) -> set[str]:
|
|
113
|
+
"""Dictation returns inflected words: 'volunteered' for 'Palantir'."""
|
|
114
|
+
forms = {word}
|
|
115
|
+
for suf in ("ing", "ed", "es", "s"):
|
|
116
|
+
if word.endswith(suf) and len(word) - len(suf) >= 5:
|
|
117
|
+
forms.add(word[: -len(suf)])
|
|
118
|
+
return forms
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def spelled(query: str) -> str | None:
|
|
122
|
+
"""'p l t r' / 'pee ell tee are' / 'P-L-T-R' -> 'PLTR', else None."""
|
|
123
|
+
raw = re.sub(r"[^a-z0-9 ]+", " ", query.lower()).replace("double u", "doubleyou")
|
|
124
|
+
toks = raw.split()
|
|
125
|
+
if not 2 <= len(toks) <= 6:
|
|
126
|
+
return None
|
|
127
|
+
out = []
|
|
128
|
+
for t in toks:
|
|
129
|
+
if len(t) == 1 and t.isalpha():
|
|
130
|
+
out.append(t)
|
|
131
|
+
elif t in LETTER_WORDS:
|
|
132
|
+
out.append(LETTER_WORDS[t])
|
|
133
|
+
else:
|
|
134
|
+
return None
|
|
135
|
+
return "".join(out).upper()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def candidates(query: str, rows: list[dict]) -> list[tuple[float, str, str]]:
|
|
139
|
+
"""Score every row. `rows` are {"t": ticker, "n": name, "r": rank}."""
|
|
140
|
+
q = normalize(query)
|
|
141
|
+
qs = strip_suffixes(q)
|
|
142
|
+
tokens = set(qs.split()) - STOPWORDS
|
|
143
|
+
if not tokens:
|
|
144
|
+
return []
|
|
145
|
+
qs = " ".join(w for w in qs.split() if w not in STOPWORDS) or qs
|
|
146
|
+
qtokens = tokens
|
|
147
|
+
|
|
148
|
+
forms = set()
|
|
149
|
+
for base in (q, qs):
|
|
150
|
+
forms |= deinflect(base)
|
|
151
|
+
skeletons = {p for p in (phonetic(f) for f in forms) if len(p) >= MIN_PHONETIC}
|
|
152
|
+
|
|
153
|
+
out = []
|
|
154
|
+
for row in rows:
|
|
155
|
+
ticker, name, rank = row["t"], row["n"], row.get("r", 0)
|
|
156
|
+
n = normalize(name)
|
|
157
|
+
ns = strip_suffixes(n)
|
|
158
|
+
heads = {ns, ns.split()[0]} if ns else {""}
|
|
159
|
+
|
|
160
|
+
if q == ticker.lower():
|
|
161
|
+
score = 1000.0
|
|
162
|
+
elif qs == ns or q == n:
|
|
163
|
+
score = 900.0
|
|
164
|
+
elif len(qs) >= MIN_PREFIX and (ns.startswith(qs) or n.startswith(q)):
|
|
165
|
+
score = 800 - min(len(ns) - len(qs), 99) * 0.1
|
|
166
|
+
elif qtokens <= set(ns.split()) and all(len(t) >= 3 for t in qtokens):
|
|
167
|
+
score = 700 - min(len(ns.split()) - len(qtokens), 99) * 0.5
|
|
168
|
+
elif ns and (overlap := qtokens & set(ns.split())):
|
|
169
|
+
# The query CONTAINS the company name plus extras: "john deere"
|
|
170
|
+
# for DEERE & CO. Requiring the query to cover most of the NAME
|
|
171
|
+
# keeps a single shared word from winning - otherwise "in video"
|
|
172
|
+
# matches Video River Networks.
|
|
173
|
+
cov = len(overlap) / max(len(set(ns.split())), 1)
|
|
174
|
+
strong = any(len(w) >= 4 and w not in SUFFIXES for w in overlap)
|
|
175
|
+
if not strong or cov < NAME_COVERAGE:
|
|
176
|
+
continue
|
|
177
|
+
score = 650 + cov * 60
|
|
178
|
+
elif skeletons and any(phonetic(h) in skeletons for h in heads):
|
|
179
|
+
ratio = max(difflib.SequenceMatcher(None, a, h).ratio()
|
|
180
|
+
for a in forms for h in heads)
|
|
181
|
+
if ratio < PHONETIC_MIN_RATIO:
|
|
182
|
+
continue
|
|
183
|
+
score = 600 + ratio * 60
|
|
184
|
+
elif len(qs) >= MIN_FUZZY:
|
|
185
|
+
ratio = max(difflib.SequenceMatcher(None, qs, h).ratio() for h in heads)
|
|
186
|
+
if ratio < FUZZY_FLOOR:
|
|
187
|
+
continue
|
|
188
|
+
score = 400 + ratio * 100
|
|
189
|
+
else:
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
out.append((score - rank * 0.002, ticker, name))
|
|
193
|
+
|
|
194
|
+
out.sort(key=lambda x: -x[0])
|
|
195
|
+
return out
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def resolve(query: str, rows: list[dict]) -> str | None:
|
|
199
|
+
"""Best ticker for a spoken query, or None when nothing is convincing."""
|
|
200
|
+
letters = spelled(query)
|
|
201
|
+
if letters and any(r["t"] == letters for r in rows):
|
|
202
|
+
return letters
|
|
203
|
+
direct = ALIASES.get(normalize(query))
|
|
204
|
+
if direct:
|
|
205
|
+
return direct
|
|
206
|
+
ranked = candidates(query, rows)
|
|
207
|
+
return ranked[0][1] if ranked else None
|
chartremotely/scales.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Spoken time-frame vocabulary. Pure logic, no macOS dependency.
|
|
2
|
+
|
|
3
|
+
Kept apart from the driver so it can be imported anywhere - by CI on Linux,
|
|
4
|
+
and by the operator, which needs to validate a spoken phrase before it has
|
|
5
|
+
any agent to send it to.
|
|
6
|
+
|
|
7
|
+
The mnemonics are chosen for phonetic distance rather than literal
|
|
8
|
+
accuracy. Digits are the worst thing to say to a recogniser: "fifteen" and
|
|
9
|
+
"fifty" collide, "one" and "won" collide. None of these ten do.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
MNEMONIC = {
|
|
17
|
+
"1d1m": "minute", "5d5m": "scalp", "5d15m": "quarter", "10d30m": "half",
|
|
18
|
+
"20d1h": "hourly", "180d4h": "swing", "1y1d": "daily", "3yw": "weekly",
|
|
19
|
+
"1d133t": "ticks", "1d10t": "micro",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
ALIAS = {
|
|
23
|
+
"minute": "1d1m", "scalp": "5d5m", "quarter": "5d15m", "half": "10d30m",
|
|
24
|
+
"hourly": "20d1h", "swing": "180d4h", "daily": "1y1d", "weekly": "3yw",
|
|
25
|
+
"ticks": "1d133t", "micro": "1d10t",
|
|
26
|
+
# variants people actually say
|
|
27
|
+
"tick": "1d133t", "ten tick": "1d10t", "quarter hour": "5d15m",
|
|
28
|
+
"half hour": "10d30m", "hour": "20d1h", "day": "1y1d", "week": "3yw",
|
|
29
|
+
"intraday": "1d1m", "swing trade": "180d4h",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
AS_IS = {"as is", "as-is", "asis", "same", "leave it", "unchanged",
|
|
33
|
+
"no change", "keep it", "current", "skip", "none", ""}
|
|
34
|
+
|
|
35
|
+
_WORDS = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6,
|
|
36
|
+
"seven": 7, "eight": 8, "nine": 9, "ten": 10, "fifteen": 15,
|
|
37
|
+
"twenty": 20, "thirty": 30, "sixty": 60, "ninety": 90}
|
|
38
|
+
_UNIT = {"d": "d", "day": "d", "days": "d", "daily": "d", "y": "y",
|
|
39
|
+
"year": "y", "years": "y", "w": "w", "week": "w", "weekly": "w",
|
|
40
|
+
"weeks": "w", "m": "m", "min": "m", "minute": "m", "minutes": "m",
|
|
41
|
+
"h": "h", "hour": "h", "hours": "h", "t": "t", "tick": "t",
|
|
42
|
+
"ticks": "t"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def canon(text: str) -> str:
|
|
46
|
+
"""'5 D : 5m' and 'five day five minute' both give '5d5m'.
|
|
47
|
+
|
|
48
|
+
Returns "" when nothing parses. Callers MUST treat that as a refusal:
|
|
49
|
+
every string ends with "", so a loose endswith match against menu
|
|
50
|
+
labels would otherwise select whichever preset came first.
|
|
51
|
+
"""
|
|
52
|
+
s = re.sub(r"[^a-z0-9 ]", " ", text.lower().replace(":", " ").replace("/", " "))
|
|
53
|
+
for word, n in _WORDS.items():
|
|
54
|
+
s = re.sub(rf"\b{word}\b", str(n), s)
|
|
55
|
+
s = re.sub(r"(\d)\s*([a-z])", r"\1\2", s)
|
|
56
|
+
out = []
|
|
57
|
+
for token in s.split():
|
|
58
|
+
m = re.match(r"^(\d+)([a-z]+)$", token)
|
|
59
|
+
if m and m.group(2) in _UNIT:
|
|
60
|
+
out.append(m.group(1) + _UNIT[m.group(2)])
|
|
61
|
+
elif token in _UNIT and out:
|
|
62
|
+
out.append(_UNIT[token])
|
|
63
|
+
return "".join(out)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def to_code(phrase: str) -> str:
|
|
67
|
+
return ALIAS.get(phrase.lower().strip(), canon(phrase))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def mnemonic_for(phrase: str) -> str | None:
|
|
71
|
+
"""The word we say back, or None if the phrase names no preset."""
|
|
72
|
+
return MNEMONIC.get(to_code(phrase))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def spoken_options() -> str:
|
|
76
|
+
"""What to read aloud when someone says something unrecognised."""
|
|
77
|
+
return ", ".join(MNEMONIC.values())
|
chartremotely/server.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Loopback listener fronting the command vocabulary.
|
|
2
|
+
|
|
3
|
+
Bound to 127.0.0.1 only. TLS is terminated by Tailscale Serve, which proxies
|
|
4
|
+
to this socket - so the wire carries a real certificate while the socket is
|
|
5
|
+
never exposed on the LAN.
|
|
6
|
+
|
|
7
|
+
Clients talk to it with an ordinary HTTPS request, which matters more than it
|
|
8
|
+
sounds: Shortcuts' SSH action re-prompts for permission every time the
|
|
9
|
+
shortcut is edited and needs a key per device, while Get Contents of URL
|
|
10
|
+
needs neither.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import secrets
|
|
17
|
+
import urllib.parse
|
|
18
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
19
|
+
|
|
20
|
+
from . import config
|
|
21
|
+
from .vocab import dispatch
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Handler(BaseHTTPRequestHandler):
|
|
25
|
+
token = ""
|
|
26
|
+
|
|
27
|
+
def _reply(self, code: int, text: str) -> None:
|
|
28
|
+
body = (text + "\n").encode()
|
|
29
|
+
self.send_response(code)
|
|
30
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
31
|
+
self.send_header("Content-Length", str(len(body)))
|
|
32
|
+
self.end_headers()
|
|
33
|
+
self.wfile.write(body)
|
|
34
|
+
|
|
35
|
+
def _authorised(self, offered) -> bool:
|
|
36
|
+
return bool(offered) and secrets.compare_digest(str(offered), self.token)
|
|
37
|
+
|
|
38
|
+
def do_POST(self) -> None:
|
|
39
|
+
if urllib.parse.urlparse(self.path).path not in ("/chart", "/"):
|
|
40
|
+
return self._reply(404, "ERR not found")
|
|
41
|
+
if not self._authorised(self.headers.get("X-Token")):
|
|
42
|
+
return self._reply(403, "ERR forbidden")
|
|
43
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
44
|
+
raw = self.rfile.read(length).decode("utf-8", "replace").strip()
|
|
45
|
+
# Shortcuts posts JSON most cleanly; curl and scripts post raw text.
|
|
46
|
+
if raw.startswith("{"):
|
|
47
|
+
try:
|
|
48
|
+
raw = str(json.loads(raw).get("cmd") or "").strip()
|
|
49
|
+
except ValueError:
|
|
50
|
+
return self._reply(400, "ERR bad JSON")
|
|
51
|
+
self._reply(200, dispatch(raw))
|
|
52
|
+
|
|
53
|
+
def do_GET(self) -> None:
|
|
54
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
55
|
+
if parsed.path not in ("/chart", "/"):
|
|
56
|
+
return self._reply(404, "ERR not found")
|
|
57
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
58
|
+
if not self._authorised((query.get("t") or [None])[0]):
|
|
59
|
+
return self._reply(403, "ERR forbidden")
|
|
60
|
+
self._reply(200, dispatch((query.get("cmd") or [""])[0].strip()))
|
|
61
|
+
|
|
62
|
+
def log_message(self, *args) -> None:
|
|
63
|
+
"""Silence. Requests carry spoken input; do not write it to a log."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def serve(port: int | None = None) -> None:
|
|
67
|
+
cfg = config.load()
|
|
68
|
+
Handler.token = config.ensure_token()
|
|
69
|
+
HTTPServer(("127.0.0.1", port or cfg["port"]), Handler).serve_forever()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""The agent's two background services, and the one-shot checks run like them.
|
|
2
|
+
|
|
3
|
+
``serve`` (the listener a Shortcut reaches over the tailnet) and ``relay``
|
|
4
|
+
(the outbound poll to the operator) run under launchd as separate agents, so
|
|
5
|
+
a relay that loses the network cannot take the listener down with it.
|
|
6
|
+
|
|
7
|
+
macOS grants Accessibility and Screen Recording to the process that asks,
|
|
8
|
+
and a launchd job is its own process, distinct from the Terminal that ran
|
|
9
|
+
``setup``. So the permission probe runs as a one-shot launchd job too: what
|
|
10
|
+
it reports is what the services will actually have.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from xml.sax.saxutils import escape
|
|
23
|
+
|
|
24
|
+
LABELS = {"serve": "com.chartremotely.serve", "relay": "com.chartremotely.relay"}
|
|
25
|
+
PROBE_LABEL = "com.chartremotely.permissions"
|
|
26
|
+
AGENTS_DIR = Path(os.path.expanduser("~/Library/LaunchAgents"))
|
|
27
|
+
LOG_DIR = Path(os.path.expanduser("~/Library/Logs"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def executable() -> str:
|
|
31
|
+
"""The installed ``chartremotely`` command, absolute."""
|
|
32
|
+
found = shutil.which("chartremotely")
|
|
33
|
+
return os.path.realpath(found) if found else os.path.realpath(sys.argv[0])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def render(label: str, args: list[str], *, keep_alive: bool, log: Path) -> str:
|
|
37
|
+
"""A LaunchAgent plist. Every value is escaped; nothing secret is ever in it."""
|
|
38
|
+
arguments = "\n".join(f" <string>{escape(a)}</string>" for a in args)
|
|
39
|
+
alive = "<true/>" if keep_alive else "<false/>"
|
|
40
|
+
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
41
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
42
|
+
<plist version="1.0">
|
|
43
|
+
<dict>
|
|
44
|
+
<key>Label</key><string>{escape(label)}</string>
|
|
45
|
+
<key>ProgramArguments</key>
|
|
46
|
+
<array>
|
|
47
|
+
{arguments}
|
|
48
|
+
</array>
|
|
49
|
+
<key>RunAtLoad</key><true/>
|
|
50
|
+
<key>KeepAlive</key>{alive}
|
|
51
|
+
<key>ThrottleInterval</key><integer>10</integer>
|
|
52
|
+
<key>StandardOutPath</key><string>{escape(str(log))}</string>
|
|
53
|
+
<key>StandardErrorPath</key><string>{escape(str(log))}</string>
|
|
54
|
+
</dict>
|
|
55
|
+
</plist>
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _domain() -> str:
|
|
60
|
+
return f"gui/{os.getuid()}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def loaded(label: str) -> bool:
|
|
64
|
+
return subprocess.run(["launchctl", "print", f"{_domain()}/{label}"],
|
|
65
|
+
capture_output=True, check=False).returncode == 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def install(command: str) -> Path:
|
|
69
|
+
"""Write and (re)start one service. Idempotent."""
|
|
70
|
+
label = LABELS[command]
|
|
71
|
+
path = AGENTS_DIR / f"{label}.plist"
|
|
72
|
+
AGENTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
path.write_text(render(label, [executable(), command], keep_alive=True,
|
|
74
|
+
log=LOG_DIR / f"chartremotely-{command}.log"))
|
|
75
|
+
if loaded(label):
|
|
76
|
+
subprocess.run(["launchctl", "bootout", f"{_domain()}/{label}"], capture_output=True, check=False)
|
|
77
|
+
subprocess.run(["launchctl", "bootstrap", _domain(), str(path)], check=True)
|
|
78
|
+
return path
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def probe_permissions(request: bool, out: Path, timeout: float = 30.0) -> dict:
|
|
82
|
+
"""Run ``chartremotely permissions`` as a one-shot launchd job and read its answer."""
|
|
83
|
+
AGENTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
path = AGENTS_DIR / f"{PROBE_LABEL}.plist"
|
|
85
|
+
out.unlink(missing_ok=True)
|
|
86
|
+
args = [executable(), "permissions", "--out", str(out)] + (["--request"] if request else [])
|
|
87
|
+
path.write_text(render(PROBE_LABEL, args, keep_alive=False, log=LOG_DIR / "chartremotely-permissions.log"))
|
|
88
|
+
subprocess.run(["launchctl", "bootout", f"{_domain()}/{PROBE_LABEL}"], capture_output=True, check=False)
|
|
89
|
+
subprocess.run(["launchctl", "bootstrap", _domain(), str(path)], check=True)
|
|
90
|
+
try:
|
|
91
|
+
deadline = time.time() + timeout
|
|
92
|
+
while time.time() < deadline and not out.exists():
|
|
93
|
+
time.sleep(0.5)
|
|
94
|
+
return json.loads(out.read_text()) if out.exists() else {}
|
|
95
|
+
finally:
|
|
96
|
+
subprocess.run(["launchctl", "bootout", f"{_domain()}/{PROBE_LABEL}"], capture_output=True, check=False)
|
|
97
|
+
path.unlink(missing_ok=True)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def permissions(request: bool) -> dict:
|
|
101
|
+
"""What this process has been granted; with ``request``, ask macOS to prompt."""
|
|
102
|
+
result = {"python": os.path.realpath(sys.executable), "accessibility": False, "screen_recording": False}
|
|
103
|
+
try:
|
|
104
|
+
from ApplicationServices import AXIsProcessTrustedWithOptions, kAXTrustedCheckOptionPrompt
|
|
105
|
+
result["accessibility"] = bool(AXIsProcessTrustedWithOptions({kAXTrustedCheckOptionPrompt: request}))
|
|
106
|
+
except ImportError:
|
|
107
|
+
pass
|
|
108
|
+
try:
|
|
109
|
+
import Quartz
|
|
110
|
+
granted = bool(Quartz.CGPreflightScreenCaptureAccess())
|
|
111
|
+
if not granted and request:
|
|
112
|
+
granted = bool(Quartz.CGRequestScreenCaptureAccess())
|
|
113
|
+
result["screen_recording"] = granted
|
|
114
|
+
except ImportError:
|
|
115
|
+
pass
|
|
116
|
+
return result
|
chartremotely/setup.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""``chartremotely setup``: from a fresh Mac to a paired, voice-driven display.
|
|
2
|
+
|
|
3
|
+
Each step checks first and skips what is already done, so running setup
|
|
4
|
+
again is always safe:
|
|
5
|
+
|
|
6
|
+
1. **Identity.** Either the patron's existing npub, proven by the Nostr DM
|
|
7
|
+
challenge (setup never sees their nsec), or a key made here for them.
|
|
8
|
+
A made key is kept for the human - their Keychain, then Safari's saved
|
|
9
|
+
passwords - and used once, in memory, to sign the pairing. It is never
|
|
10
|
+
written to this agent's config.
|
|
11
|
+
2. **Pairing**, without a code to copy: setup adopts this machine's code itself.
|
|
12
|
+
3. **Tailscale**: the Mac's tailnet address, with ``/chart`` forwarded to the agent.
|
|
13
|
+
4. **Services**: the listener and the relay, under launchd.
|
|
14
|
+
5. **Permissions**: Accessibility and Screen Recording, asked for by the
|
|
15
|
+
services' own Python, since that is the process macOS grants them to.
|
|
16
|
+
6. **The voice Shortcut**: this Mac's copy, opened for import.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import subprocess
|
|
22
|
+
import tempfile
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from . import config, mcpclient, relay
|
|
27
|
+
|
|
28
|
+
OPERATOR_URL = "https://chartremotely-mcp.fastmcp.app"
|
|
29
|
+
SITE = "https://chartremotely.tollbooth-dpyc.com"
|
|
30
|
+
SAVE_KEY_PAGE = f"{SITE}/#/save-key"
|
|
31
|
+
|
|
32
|
+
Ask = Callable[[str], str]
|
|
33
|
+
Say = Callable[[str], None]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SetupError(RuntimeError):
|
|
37
|
+
"""A step that cannot continue, with the reason a person can act on."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# -- identity -----------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def prove_by_dm(client: mcpclient.Client, npub: str, ask: Ask, say: Say) -> str:
|
|
43
|
+
"""Prove an existing npub by the Secure Courier DM. Returns the dpop_token.
|
|
44
|
+
|
|
45
|
+
The human answers from their own Nostr client, so no key passes through here.
|
|
46
|
+
"""
|
|
47
|
+
sent = client.call("chart_request_npub_proof", {
|
|
48
|
+
"patron_npub": npub, "verify_at": SITE,
|
|
49
|
+
"reason": "You asked to pair a Mac with ChartRemotely."})
|
|
50
|
+
phrase = sent.get("dpop_token")
|
|
51
|
+
if not phrase:
|
|
52
|
+
raise SetupError(sent.get("error") or "the operator did not send a proof request")
|
|
53
|
+
say(f"A Nostr DM is on its way to {npub[:12]}…")
|
|
54
|
+
say(f"Its confirmation code is: {phrase}")
|
|
55
|
+
say("Reply to the DM from your Nostr client only if the codes match.")
|
|
56
|
+
ask("Press Return once you have replied… ")
|
|
57
|
+
got = client.call("chart_receive_npub_proof", {"patron_npub": npub, "dpop_token": phrase})
|
|
58
|
+
if got.get("error"):
|
|
59
|
+
raise SetupError(got["error"])
|
|
60
|
+
return got.get("dpop_token") or phrase
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def new_key() -> tuple[str, str]:
|
|
64
|
+
"""A fresh Nostr keypair, made by the SDK's own key code. Returns (npub, nsec)."""
|
|
65
|
+
from pynostr.key import PrivateKey # the tollbooth-dpyc SDK's key library
|
|
66
|
+
|
|
67
|
+
key = PrivateKey()
|
|
68
|
+
return key.public_key.bech32(), key.bech32()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def sign_once(nsec: str, tool: str) -> str:
|
|
72
|
+
"""One kind-27235 proof for one tool call, signed by the SDK."""
|
|
73
|
+
from tollbooth.identity_proof import create_proof
|
|
74
|
+
|
|
75
|
+
return create_proof(nsec, tool)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def keep_for_human(npub: str, nsec: str, ask: Ask, say: Say) -> None:
|
|
79
|
+
"""Store a made key in the Keychain and let Safari save it to iCloud Passwords."""
|
|
80
|
+
from . import keystore
|
|
81
|
+
|
|
82
|
+
keystore.save(npub, nsec)
|
|
83
|
+
say("Your key is saved in this Mac's Keychain, under “ChartRemotely — Nostr key”.")
|
|
84
|
+
say("Next, Safari saves it to your iCloud Passwords so your other devices have it.")
|
|
85
|
+
timer = keystore.copy_briefly(nsec)
|
|
86
|
+
try:
|
|
87
|
+
subprocess.run(["open", f"{SAVE_KEY_PAGE}?npub={npub}"], check=False)
|
|
88
|
+
say("Paste the key (⌘V) into the page, press Save, and accept Safari's offer to save it.")
|
|
89
|
+
ask("Press Return when Safari has saved it… ")
|
|
90
|
+
finally:
|
|
91
|
+
timer.cancel()
|
|
92
|
+
keystore.clear_if_unchanged(nsec)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def identity(client: mcpclient.Client, ask: Ask, say: Say) -> tuple[str, str]:
|
|
96
|
+
"""Who owns this display, proven. Returns (npub, proof for chart_pair_agent)."""
|
|
97
|
+
say("Your Nostr identity owns this display.")
|
|
98
|
+
say(" 1 I have an npub (I'll answer a Nostr DM to prove it)")
|
|
99
|
+
say(" 2 Use a key saved on this Mac")
|
|
100
|
+
say(" 3 Make me a new key")
|
|
101
|
+
choice = ask("Choose 1, 2 or 3: ").strip()
|
|
102
|
+
if choice == "1":
|
|
103
|
+
npub = ask("Your npub: ").strip()
|
|
104
|
+
if not npub.startswith("npub1"):
|
|
105
|
+
raise SetupError("that is not an npub")
|
|
106
|
+
return npub, prove_by_dm(client, npub, ask, say)
|
|
107
|
+
if choice == "2":
|
|
108
|
+
from . import keystore
|
|
109
|
+
|
|
110
|
+
saved = keystore.saved_npubs()
|
|
111
|
+
if not saved:
|
|
112
|
+
raise SetupError("no ChartRemotely key is saved on this Mac yet; choose 1 or 3")
|
|
113
|
+
for i, npub in enumerate(saved, 1):
|
|
114
|
+
say(f" {i} {npub}")
|
|
115
|
+
picked = saved[int(ask("Which key? ").strip() or "1") - 1]
|
|
116
|
+
nsec = keystore.load(picked) # macOS asks the human first
|
|
117
|
+
try:
|
|
118
|
+
return picked, sign_once(nsec, "chart_pair_agent")
|
|
119
|
+
finally:
|
|
120
|
+
del nsec
|
|
121
|
+
if choice == "3":
|
|
122
|
+
npub, nsec = new_key()
|
|
123
|
+
try:
|
|
124
|
+
say(f"Your new npub is {npub}")
|
|
125
|
+
keep_for_human(npub, nsec, ask, say)
|
|
126
|
+
return npub, sign_once(nsec, "chart_pair_agent")
|
|
127
|
+
finally:
|
|
128
|
+
del nsec
|
|
129
|
+
raise SetupError("choose 1, 2 or 3")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# -- pairing ------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def pair(client: mcpclient.Client, base: str, npub: str, proof: str, label: str) -> dict:
|
|
135
|
+
"""Adopt this machine's pairing code with the patron's proof, then collect."""
|
|
136
|
+
code, expires_in = relay.open_code(base)
|
|
137
|
+
answer = client.call("chart_pair_agent", {"code": code, "label": label, "npub": npub, "dpop_token": proof})
|
|
138
|
+
if not answer.get("ok"):
|
|
139
|
+
raise SetupError(answer.get("error") or "the operator refused the pairing")
|
|
140
|
+
return relay.collect(base, code, expires_in)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# -- the whole run ------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def run(ask: Ask = input, say: Say = print, operator_url: str = OPERATOR_URL) -> int:
|
|
146
|
+
cfg = config.load()
|
|
147
|
+
base = (cfg.get("operator_url") or operator_url).rstrip("/")
|
|
148
|
+
token = config.ensure_token()
|
|
149
|
+
|
|
150
|
+
# 1-2. Identity and pairing, unless this Mac is already paired.
|
|
151
|
+
if cfg.get("agent_id") and cfg.get("agent_secret"):
|
|
152
|
+
say(f"This Mac is already paired ({cfg['agent_id']}).")
|
|
153
|
+
npub = ""
|
|
154
|
+
else:
|
|
155
|
+
client = mcpclient.Client(base)
|
|
156
|
+
npub, proof = identity(client, ask, say)
|
|
157
|
+
label = ask("Name this display (e.g. Desk, Office wall): ").strip() or "display"
|
|
158
|
+
pair(client, base, npub, proof, label)
|
|
159
|
+
del proof
|
|
160
|
+
say(f"Paired as “{label}”.")
|
|
161
|
+
|
|
162
|
+
# 3. Tailscale.
|
|
163
|
+
from . import tailnet
|
|
164
|
+
|
|
165
|
+
url = tailnet.chart_url(tailnet.status())
|
|
166
|
+
tailnet.serve(int(cfg.get("port") or 8899))
|
|
167
|
+
say(f"Your Shortcut will reach this Mac at {url}")
|
|
168
|
+
|
|
169
|
+
# 4. Services.
|
|
170
|
+
from . import services
|
|
171
|
+
|
|
172
|
+
for command in ("serve", "relay"):
|
|
173
|
+
services.install(command)
|
|
174
|
+
say("The listener and the relay are running, and start with this Mac.")
|
|
175
|
+
|
|
176
|
+
# 5. Permissions, as the services' own Python sees them.
|
|
177
|
+
probe = Path(tempfile.gettempdir()) / "chartremotely-permissions.json"
|
|
178
|
+
granted = services.probe_permissions(request=True, out=probe)
|
|
179
|
+
while not (granted.get("accessibility") and granted.get("screen_recording")):
|
|
180
|
+
python = granted.get("python", "the Python that runs ChartRemotely")
|
|
181
|
+
missing = [name for name, key in (("Accessibility", "accessibility"),
|
|
182
|
+
("Screen Recording", "screen_recording")) if not granted.get(key)]
|
|
183
|
+
say(f"macOS needs you to allow {' and '.join(missing)} for: {python}")
|
|
184
|
+
pane = "Privacy_Accessibility" if not granted.get("accessibility") else "Privacy_ScreenCapture"
|
|
185
|
+
subprocess.run(["open", f"x-apple.systempreferences:com.apple.preference.security?{pane}"], check=False)
|
|
186
|
+
ask("Turn it on in System Settings, then press Return… ")
|
|
187
|
+
granted = services.probe_permissions(request=False, out=probe)
|
|
188
|
+
say("Accessibility and Screen Recording are allowed.")
|
|
189
|
+
|
|
190
|
+
# 6. The voice Shortcut.
|
|
191
|
+
from . import shortcut
|
|
192
|
+
|
|
193
|
+
made = shortcut.build(url, token)
|
|
194
|
+
subprocess.run(["open", str(made)], check=False)
|
|
195
|
+
say("Add the ChartRemotely Shortcut when it opens; iCloud brings it to your iPhone, iPad and Watch.")
|
|
196
|
+
|
|
197
|
+
say("")
|
|
198
|
+
say("Done. Say “Hey Siri, ChartRemotely” from any of your devices.")
|
|
199
|
+
if npub:
|
|
200
|
+
say(f"Your npub: {npub}")
|
|
201
|
+
say(f"Sign in at {SITE} to see your screens.")
|
|
202
|
+
return 0
|