callwalkietalkie 0.8.0
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.
- package/README.md +36 -0
- package/bin/callwalkietalkie.js +340 -0
- package/bin/longleash.js +3 -0
- package/package.json +42 -0
- package/runtime/agent_ui.html +1118 -0
- package/runtime/cmux-adapter/cmux_adapter.py +985 -0
- package/runtime/requirements.txt +3 -0
- package/runtime/winproxy.py +960 -0
- package/scripts/sync-runtime.mjs +33 -0
|
@@ -0,0 +1,960 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
callwalkietalkie — phone chat into a cmux workspace.
|
|
4
|
+
|
|
5
|
+
Requires cmux running on this Mac. Pair over LAN with a QR code, then every
|
|
6
|
+
message goes through cmux's socket (surface.send_text / surface.read_text).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import atexit
|
|
12
|
+
import argparse
|
|
13
|
+
import base64
|
|
14
|
+
import hashlib
|
|
15
|
+
import hmac
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import secrets
|
|
20
|
+
import signal
|
|
21
|
+
import socket
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
import webbrowser
|
|
27
|
+
|
|
28
|
+
import qrcode
|
|
29
|
+
from flask import Flask, Response, abort, jsonify, request
|
|
30
|
+
from qrcode.constants import ERROR_CORRECT_H
|
|
31
|
+
|
|
32
|
+
# cmux adapter lives in a sibling folder (kept isolated on purpose).
|
|
33
|
+
_ADAPTER_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cmux-adapter")
|
|
34
|
+
if _ADAPTER_DIR not in sys.path:
|
|
35
|
+
sys.path.insert(0, _ADAPTER_DIR)
|
|
36
|
+
from cmux_adapter import ( # noqa: E402
|
|
37
|
+
CmuxError,
|
|
38
|
+
inventory,
|
|
39
|
+
open_session,
|
|
40
|
+
ping as cmux_ping,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
app = Flask(__name__)
|
|
44
|
+
TOKEN = os.environ.get("LONGLEASH_TOKEN", "agnostic-dispatch")
|
|
45
|
+
PORT = int(os.environ.get("LONGLEASH_PORT", 8787))
|
|
46
|
+
LANDING_URL = os.environ.get(
|
|
47
|
+
"LONGLEASH_LANDING", "https://callwalkietalkie.com"
|
|
48
|
+
).rstrip("/")
|
|
49
|
+
# Public relay the Mac dials out to. Set LONGLEASH_RELAY=off to stay LAN-only.
|
|
50
|
+
DEFAULT_RELAY = "https://callwalkietalkie.com"
|
|
51
|
+
RELAY_URL = (
|
|
52
|
+
os.environ.get("CALLWALKIETALKIE_RELAY")
|
|
53
|
+
or os.environ.get("LONGLEASH_RELAY")
|
|
54
|
+
or DEFAULT_RELAY
|
|
55
|
+
).rstrip("/")
|
|
56
|
+
|
|
57
|
+
CAFFEINATE_SECONDS = 60 * 60
|
|
58
|
+
_caffeinate_seconds = CAFFEINATE_SECONDS
|
|
59
|
+
_caffeinate_proc = None
|
|
60
|
+
_caffeinate_atexit = False
|
|
61
|
+
|
|
62
|
+
_agent_lock = threading.Lock()
|
|
63
|
+
_session = None # cmux Session
|
|
64
|
+
_session_ws = None
|
|
65
|
+
_session_surface = None
|
|
66
|
+
_sent_by_surface: dict = {}
|
|
67
|
+
_notified = False
|
|
68
|
+
|
|
69
|
+
# Machine key from callwalkietalkie (LONGLEASH_KEY). Session is issued by the
|
|
70
|
+
# relay as a 6-digit HMAC handshake, or derived locally when relay is off.
|
|
71
|
+
MACHINE_KEY = (
|
|
72
|
+
os.environ.get("CALLWALKIETALKIE_KEY") or os.environ.get("LONGLEASH_KEY") or ""
|
|
73
|
+
).strip()
|
|
74
|
+
SESSION_CODE = ""
|
|
75
|
+
KEY_FINGERPRINT = ""
|
|
76
|
+
_phone_arrived = False
|
|
77
|
+
_tunnel_lock = threading.Lock()
|
|
78
|
+
_tunnel_ws = None
|
|
79
|
+
_session_ready = threading.Event()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _set_session(code: str, fingerprint: str = "") -> None:
|
|
83
|
+
global SESSION_CODE, KEY_FINGERPRINT
|
|
84
|
+
SESSION_CODE = str(code or "")
|
|
85
|
+
if fingerprint:
|
|
86
|
+
KEY_FINGERPRINT = fingerprint
|
|
87
|
+
if SESSION_CODE:
|
|
88
|
+
_session_ready.set()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _lan_session_from_key(key: str) -> str:
|
|
92
|
+
"""Same shape as relay sessions: 6 digits from HMAC(key)."""
|
|
93
|
+
digest = hmac.new(
|
|
94
|
+
b"callwalkietalkie-lan-v1", key.encode("utf-8"), hashlib.sha256
|
|
95
|
+
).digest()
|
|
96
|
+
return f"{int.from_bytes(digest[:4], 'big') % 1_000_000:06d}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _bootstrap_session() -> None:
|
|
100
|
+
"""LAN-only or pre-relay: derive a local 6-digit code from the machine key."""
|
|
101
|
+
if SESSION_CODE:
|
|
102
|
+
return
|
|
103
|
+
if MACHINE_KEY:
|
|
104
|
+
fp = hashlib.sha256(MACHINE_KEY.encode("utf-8")).hexdigest()[:8]
|
|
105
|
+
_set_session(_lan_session_from_key(MACHINE_KEY), fp)
|
|
106
|
+
else:
|
|
107
|
+
_set_session(f"{secrets.randbelow(1_000_000):06d}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------- auth
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.before_request
|
|
114
|
+
def check_token():
|
|
115
|
+
if request.path.startswith("/static"):
|
|
116
|
+
return
|
|
117
|
+
# Pairing landing is unguessable by session code, not by token.
|
|
118
|
+
if request.path.startswith("/p/"):
|
|
119
|
+
return
|
|
120
|
+
t = request.args.get("t") or request.headers.get("X-Longleash-Token")
|
|
121
|
+
if t != TOKEN:
|
|
122
|
+
abort(401)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------- cmux session
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _agent_error_message(exc):
|
|
129
|
+
if isinstance(exc, SystemExit):
|
|
130
|
+
msg = exc.code if isinstance(exc.code, str) else (exc.args[0] if exc.args else "cmux unavailable")
|
|
131
|
+
return str(msg)
|
|
132
|
+
return str(exc) or "cmux unavailable"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _relay_enabled() -> bool:
|
|
136
|
+
return bool(RELAY_URL) and RELAY_URL.lower() not in {"off", "0", "false", "none"}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _public_scan_url() -> str:
|
|
140
|
+
code = SESSION_CODE or "······"
|
|
141
|
+
if _relay_enabled():
|
|
142
|
+
return f"{RELAY_URL}/p/{code}"
|
|
143
|
+
return f"http://{lan_ip()}:{PORT}/p/{code}"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _public_pair_url() -> str:
|
|
147
|
+
if _relay_enabled() and SESSION_CODE:
|
|
148
|
+
return f"{RELAY_URL}/pair/{SESSION_CODE}"
|
|
149
|
+
return f"http://127.0.0.1:{PORT}/setup?t={TOKEN}"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _api_base() -> str:
|
|
153
|
+
prefix = (request.headers.get("X-Longleash-Prefix") or "").rstrip("/")
|
|
154
|
+
return f"{prefix}/agent" if prefix else "/agent"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _tunnel_ws_url() -> str:
|
|
158
|
+
if RELAY_URL.startswith("https://"):
|
|
159
|
+
return "wss://" + RELAY_URL[len("https://") :] + "/host"
|
|
160
|
+
if RELAY_URL.startswith("http://"):
|
|
161
|
+
return "ws://" + RELAY_URL[len("http://") :] + "/host"
|
|
162
|
+
return RELAY_URL + "/host"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _handle_tunnel_req(msg: dict) -> None:
|
|
166
|
+
global _tunnel_ws
|
|
167
|
+
from werkzeug.test import Client
|
|
168
|
+
|
|
169
|
+
client = Client(app)
|
|
170
|
+
path = msg.get("path") or "/"
|
|
171
|
+
query = msg.get("query") or ""
|
|
172
|
+
raw = base64.b64decode(msg.get("body") or "")
|
|
173
|
+
headers = {"X-Longleash-Prefix": msg.get("prefix") or ""}
|
|
174
|
+
extra = msg.get("headers") or {}
|
|
175
|
+
content_type = extra.get("content-type") or extra.get("Content-Type") or None
|
|
176
|
+
try:
|
|
177
|
+
resp = client.open(
|
|
178
|
+
path,
|
|
179
|
+
method=msg.get("method") or "GET",
|
|
180
|
+
query_string=query,
|
|
181
|
+
data=raw or None,
|
|
182
|
+
headers=headers,
|
|
183
|
+
content_type=content_type,
|
|
184
|
+
)
|
|
185
|
+
out_headers = {}
|
|
186
|
+
for key, value in resp.headers.items():
|
|
187
|
+
lk = key.lower()
|
|
188
|
+
if lk in {"content-type", "location", "cache-control", "pragma"}:
|
|
189
|
+
out_headers[lk] = value
|
|
190
|
+
payload = {
|
|
191
|
+
"type": "res",
|
|
192
|
+
"id": msg.get("id"),
|
|
193
|
+
"status": resp.status_code,
|
|
194
|
+
"headers": out_headers,
|
|
195
|
+
"body": base64.b64encode(resp.get_data() or b"").decode("ascii"),
|
|
196
|
+
}
|
|
197
|
+
except Exception as e:
|
|
198
|
+
payload = {
|
|
199
|
+
"type": "res",
|
|
200
|
+
"id": msg.get("id"),
|
|
201
|
+
"status": 500,
|
|
202
|
+
"headers": {"content-type": "application/json"},
|
|
203
|
+
"body": base64.b64encode(
|
|
204
|
+
json.dumps({"ok": False, "error": str(e)}).encode()
|
|
205
|
+
).decode("ascii"),
|
|
206
|
+
}
|
|
207
|
+
data = json.dumps(payload)
|
|
208
|
+
with _tunnel_lock:
|
|
209
|
+
ws = _tunnel_ws
|
|
210
|
+
if ws is not None:
|
|
211
|
+
try:
|
|
212
|
+
ws.send(data)
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _tunnel_loop() -> None:
|
|
218
|
+
global _tunnel_ws
|
|
219
|
+
try:
|
|
220
|
+
import websocket
|
|
221
|
+
except ImportError:
|
|
222
|
+
print(" relay skipped · pip install websocket-client")
|
|
223
|
+
return
|
|
224
|
+
if not MACHINE_KEY:
|
|
225
|
+
print(" relay skipped · missing key (run via npx callwalkietalkie)")
|
|
226
|
+
return
|
|
227
|
+
backoff = 1.0
|
|
228
|
+
url = _tunnel_ws_url()
|
|
229
|
+
while True:
|
|
230
|
+
ws = None
|
|
231
|
+
try:
|
|
232
|
+
ws = websocket.create_connection(url, timeout=30)
|
|
233
|
+
with _tunnel_lock:
|
|
234
|
+
_tunnel_ws = ws
|
|
235
|
+
ws.send(json.dumps({"type": "hello", "key": MACHINE_KEY}))
|
|
236
|
+
raw = ws.recv()
|
|
237
|
+
if not raw:
|
|
238
|
+
raise RuntimeError("empty hello reply")
|
|
239
|
+
hello = json.loads(raw)
|
|
240
|
+
if hello.get("type") != "ok" or not hello.get("session"):
|
|
241
|
+
err = hello.get("error") or "bad hello"
|
|
242
|
+
print(f" relay rejected · {err}")
|
|
243
|
+
raise RuntimeError(err)
|
|
244
|
+
_set_session(str(hello["session"]), str(hello.get("fingerprint") or ""))
|
|
245
|
+
backoff = 1.0
|
|
246
|
+
print(f" relay live · session {SESSION_CODE} · {_public_scan_url()}")
|
|
247
|
+
while True:
|
|
248
|
+
raw = ws.recv()
|
|
249
|
+
if not raw:
|
|
250
|
+
break
|
|
251
|
+
msg = json.loads(raw)
|
|
252
|
+
if msg.get("type") == "req":
|
|
253
|
+
threading.Thread(
|
|
254
|
+
target=_handle_tunnel_req, args=(msg,), daemon=True
|
|
255
|
+
).start()
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
with _tunnel_lock:
|
|
259
|
+
if _tunnel_ws is ws:
|
|
260
|
+
_tunnel_ws = None
|
|
261
|
+
try:
|
|
262
|
+
if ws is not None:
|
|
263
|
+
ws.close()
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
time.sleep(backoff)
|
|
267
|
+
backoff = min(backoff * 2, 20.0)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _get_session(workspace=None, surface=None, window=None, *, force=False):
|
|
271
|
+
global _session, _session_ws, _session_surface, _notified
|
|
272
|
+
want_ws = workspace if workspace is not None else _session_ws
|
|
273
|
+
want_sf = surface if surface is not None else _session_surface
|
|
274
|
+
# Persist phone-echoed sends per surface before swapping sessions.
|
|
275
|
+
if _session is not None and _session_surface:
|
|
276
|
+
_sent_by_surface[_session_surface] = list(_session._sent)
|
|
277
|
+
fresh = (
|
|
278
|
+
force
|
|
279
|
+
or _session is None
|
|
280
|
+
or (want_ws and want_ws != _session_ws)
|
|
281
|
+
or (want_sf and want_sf != _session_surface)
|
|
282
|
+
)
|
|
283
|
+
if fresh:
|
|
284
|
+
_session = open_session(workspace=want_ws, surface=want_sf, window=window)
|
|
285
|
+
_session_ws = _session.target.workspace_ref
|
|
286
|
+
_session_surface = _session.target.surface_ref
|
|
287
|
+
_session._sent = list(_sent_by_surface.get(_session_surface, []))
|
|
288
|
+
if not _notified:
|
|
289
|
+
_session.notify_linked()
|
|
290
|
+
_notified = True
|
|
291
|
+
else:
|
|
292
|
+
try:
|
|
293
|
+
_session.refresh_target_meta()
|
|
294
|
+
except CmuxError:
|
|
295
|
+
pass
|
|
296
|
+
return _session
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# ---------------------------------------------------------------- setup UI
|
|
300
|
+
|
|
301
|
+
PAPER_CSS = """
|
|
302
|
+
:root {
|
|
303
|
+
--canvas:#ffffff; --soft:#f7f6f3; --ink:#37352f;
|
|
304
|
+
--muted:#787774; --faint:#9b9a97;
|
|
305
|
+
--line:rgba(55,53,47,0.09); --line-strong:rgba(55,53,47,0.16);
|
|
306
|
+
--action:#2383e2; --cta:#0075de; --cta-hover:#005bab; --ok:#448361;
|
|
307
|
+
--code-bg:#f9f8f7;
|
|
308
|
+
--mono:"SFMono-Regular", Menlo, Consolas, "Liberation Mono", Courier, monospace;
|
|
309
|
+
}
|
|
310
|
+
* { box-sizing:border-box; margin:0; padding:0; }
|
|
311
|
+
[hidden] { display:none !important; }
|
|
312
|
+
html, body { height:100%; }
|
|
313
|
+
body {
|
|
314
|
+
min-height:100%; display:flex; flex-direction:column;
|
|
315
|
+
font-family:Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
316
|
+
background:var(--canvas); color:var(--ink);
|
|
317
|
+
font-size:14px; line-height:1.55; -webkit-font-smoothing:antialiased;
|
|
318
|
+
}
|
|
319
|
+
.top {
|
|
320
|
+
flex:none; display:flex; align-items:center; justify-content:space-between; gap:16px;
|
|
321
|
+
padding:13px 28px; border-bottom:1px solid var(--line);
|
|
322
|
+
}
|
|
323
|
+
.brand {
|
|
324
|
+
display:inline-flex; align-items:center; gap:7px;
|
|
325
|
+
font-size:15px; font-weight:600; color:var(--ink);
|
|
326
|
+
}
|
|
327
|
+
.brand .lasso { flex:none; color:#000; }
|
|
328
|
+
.meta { font-size:12px; color:var(--muted); }
|
|
329
|
+
.stage {
|
|
330
|
+
flex:1; display:flex; align-items:center; justify-content:center;
|
|
331
|
+
width:100%; padding:24px 0;
|
|
332
|
+
}
|
|
333
|
+
main { width:100%; max-width:640px; margin:0 auto; padding:0 28px; }
|
|
334
|
+
h1 { font-size:22px; font-weight:600; letter-spacing:-0.02em; line-height:1.2; }
|
|
335
|
+
.steps { list-style:none; }
|
|
336
|
+
.step { position:relative; display:flex; gap:14px; padding-bottom:30px; }
|
|
337
|
+
.step:last-child { padding-bottom:0; }
|
|
338
|
+
.rail { flex:none; width:18px; }
|
|
339
|
+
.wire {
|
|
340
|
+
position:absolute; top:23px; bottom:2px; left:8px; width:2px;
|
|
341
|
+
border-radius:1px; background:var(--line); overflow:hidden;
|
|
342
|
+
}
|
|
343
|
+
.wire span {
|
|
344
|
+
display:block; width:100%; height:0;
|
|
345
|
+
background:var(--ink); transition:height 300ms ease;
|
|
346
|
+
}
|
|
347
|
+
.box {
|
|
348
|
+
flex:none; width:18px; height:18px; margin-top:1px;
|
|
349
|
+
border:1px solid var(--line-strong); border-radius:4px;
|
|
350
|
+
display:flex; align-items:center; justify-content:center;
|
|
351
|
+
transition:background 150ms ease, border-color 150ms ease;
|
|
352
|
+
}
|
|
353
|
+
.box svg { opacity:0; transition:opacity 150ms ease; }
|
|
354
|
+
.step[data-state="done"] .box, .box.done { background:var(--ink); border-color:var(--ink); }
|
|
355
|
+
.step[data-state="done"] .box svg, .box.done svg { opacity:1; }
|
|
356
|
+
.step-body { flex:1; min-width:0; }
|
|
357
|
+
.step-head { display:flex; align-items:baseline; justify-content:space-between; gap:16px; }
|
|
358
|
+
.eyebrow {
|
|
359
|
+
font-size:11px; font-weight:600; letter-spacing:0.06em;
|
|
360
|
+
text-transform:uppercase; color:var(--faint);
|
|
361
|
+
}
|
|
362
|
+
.step[data-state="done"] .eyebrow { color:var(--ok); }
|
|
363
|
+
.label { font-size:16px; font-weight:600; letter-spacing:-0.01em; }
|
|
364
|
+
.status {
|
|
365
|
+
flex:none; display:flex; align-items:center; gap:6px;
|
|
366
|
+
font-size:12px; color:var(--faint); font-variant-numeric:tabular-nums;
|
|
367
|
+
}
|
|
368
|
+
.status .dot { width:6px; height:6px; border-radius:50%; background:currentColor; }
|
|
369
|
+
.step[data-state="waiting"] .status .dot { animation:pulse 1.6s linear infinite; }
|
|
370
|
+
.step[data-state="done"] .status { color:var(--ok); }
|
|
371
|
+
.step[data-state="done"] .status .dot { animation:none; }
|
|
372
|
+
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:.25; } }
|
|
373
|
+
.hint { margin-top:6px; color:var(--muted); }
|
|
374
|
+
.qr {
|
|
375
|
+
display:flex; align-items:center; gap:16px; margin-top:12px; padding:16px;
|
|
376
|
+
border:1px solid var(--line-strong); border-radius:8px;
|
|
377
|
+
}
|
|
378
|
+
.qr img { flex:none; display:block; }
|
|
379
|
+
.qr-aside { min-width:0; }
|
|
380
|
+
.qr-aside p { color:var(--muted); }
|
|
381
|
+
.session { margin-bottom:12px; }
|
|
382
|
+
.session strong {
|
|
383
|
+
font-family:var(--mono); font-size:15px; font-weight:500;
|
|
384
|
+
letter-spacing:0.04em; color:var(--ink);
|
|
385
|
+
}
|
|
386
|
+
.qr-url {
|
|
387
|
+
display:inline-block; margin-top:6px; max-width:100%;
|
|
388
|
+
overflow-wrap:anywhere; color:var(--action); text-decoration:none;
|
|
389
|
+
}
|
|
390
|
+
.qr-url:hover { color:var(--cta-hover); text-decoration:underline; }
|
|
391
|
+
.countdown { color:var(--ink); font-weight:600; font-variant-numeric:tabular-nums; }
|
|
392
|
+
.centered { max-width:380px; margin:0 auto; padding:0 28px; text-align:center; }
|
|
393
|
+
.centered .box { margin:0 auto 16px; width:28px; height:28px; }
|
|
394
|
+
.centered .box svg { transform:scale(1.4); }
|
|
395
|
+
.centered p { color:var(--muted); margin-top:8px; }
|
|
396
|
+
@media (max-width:420px) {
|
|
397
|
+
main, .centered { padding-left:18px; padding-right:18px; }
|
|
398
|
+
.top { padding-left:18px; padding-right:18px; }
|
|
399
|
+
.stage { align-items:flex-start; padding-top:28px; }
|
|
400
|
+
.qr { flex-direction:column; align-items:flex-start; }
|
|
401
|
+
}
|
|
402
|
+
"""
|
|
403
|
+
|
|
404
|
+
_CHECK_SVG = """<svg width="11" height="11" viewBox="0 0 11 11" fill="none" aria-hidden="true">
|
|
405
|
+
<path d="M1.5 5.8 4 8.3 9.5 2.8" stroke="#fff" stroke-width="1.8"
|
|
406
|
+
stroke-linecap="round" stroke-linejoin="round"/></svg>"""
|
|
407
|
+
|
|
408
|
+
_LASSO_SVG = """<svg class="lasso" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
409
|
+
<ellipse cx="10.5" cy="10" rx="7" ry="6.5" stroke="currentColor" stroke-width="1.85"/>
|
|
410
|
+
<path d="M16.2 13.8c1.6 1.4 3.4 3.8 4.6 6.4" stroke="currentColor" stroke-width="1.85" stroke-linecap="round"/>
|
|
411
|
+
</svg>"""
|
|
412
|
+
|
|
413
|
+
_BRAND = f'<span class="brand">{_LASSO_SVG}Call Walkie Talkie</span>'
|
|
414
|
+
|
|
415
|
+
SETUP_HTML = """<!doctype html>
|
|
416
|
+
<html lang="en"><head>
|
|
417
|
+
<meta charset="utf-8">
|
|
418
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
419
|
+
<title>Link your phone — Call Walkie Talkie</title>
|
|
420
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
421
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
422
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
423
|
+
<style>__CSS__</style>
|
|
424
|
+
</head><body>
|
|
425
|
+
<div class="top">__BRAND__<span class="meta">Setup · cmux</span></div>
|
|
426
|
+
<div class="stage">
|
|
427
|
+
<main>
|
|
428
|
+
<ol class="steps">
|
|
429
|
+
<li class="step" id="step-computer" data-state="done">
|
|
430
|
+
<div class="rail">
|
|
431
|
+
<span class="box">__CHECK__</span>
|
|
432
|
+
<span class="wire" aria-hidden="true"><span id="wire-fill" style="height:50%"></span></span>
|
|
433
|
+
</div>
|
|
434
|
+
<div class="step-body">
|
|
435
|
+
<div class="step-head">
|
|
436
|
+
<div>
|
|
437
|
+
<p class="eyebrow">Step 1/2</p>
|
|
438
|
+
<h2 class="label">Link your computer</h2>
|
|
439
|
+
</div>
|
|
440
|
+
<div class="status"><span class="dot"></span><span>__HOSTNAME__</span></div>
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
</li>
|
|
444
|
+
|
|
445
|
+
<li class="step" id="step-phone" data-state="waiting">
|
|
446
|
+
<div class="rail"><span class="box">__CHECK__</span></div>
|
|
447
|
+
<div class="step-body">
|
|
448
|
+
<div class="step-head">
|
|
449
|
+
<div>
|
|
450
|
+
<p class="eyebrow">Step 2/2</p>
|
|
451
|
+
<h2 class="label">Link your phone</h2>
|
|
452
|
+
</div>
|
|
453
|
+
<div class="status"><span class="dot"></span><span id="meta-phone">Waiting for a scan</span></div>
|
|
454
|
+
</div>
|
|
455
|
+
<div class="qr" id="qr-block">
|
|
456
|
+
__QR__
|
|
457
|
+
<div class="qr-aside">
|
|
458
|
+
<p class="session">Session <strong>__CODE__</strong></p>
|
|
459
|
+
<p>If it won't scan, open this on your phone instead:</p>
|
|
460
|
+
<a class="qr-url" href="__SCAN_URL__">__SCAN_URL__</a>
|
|
461
|
+
</div>
|
|
462
|
+
</div>
|
|
463
|
+
<p class="hint" id="done-detail" hidden></p>
|
|
464
|
+
</div>
|
|
465
|
+
</li>
|
|
466
|
+
</ol>
|
|
467
|
+
</main>
|
|
468
|
+
</div>
|
|
469
|
+
<script>
|
|
470
|
+
(function() {
|
|
471
|
+
var TOKEN = "__TOKEN__";
|
|
472
|
+
var CHAT = "/agent?t=" + encodeURIComponent(TOKEN);
|
|
473
|
+
var SECONDS = 3;
|
|
474
|
+
var stepPhone = document.getElementById("step-phone");
|
|
475
|
+
var metaPhone = document.getElementById("meta-phone");
|
|
476
|
+
var wireFill = document.getElementById("wire-fill");
|
|
477
|
+
var qrBlock = document.getElementById("qr-block");
|
|
478
|
+
var doneDetail = document.getElementById("done-detail");
|
|
479
|
+
var timer = null;
|
|
480
|
+
|
|
481
|
+
function arrive() {
|
|
482
|
+
clearInterval(timer);
|
|
483
|
+
stepPhone.dataset.state = "done";
|
|
484
|
+
metaPhone.textContent = "Connected";
|
|
485
|
+
wireFill.style.height = "100%";
|
|
486
|
+
qrBlock.hidden = true;
|
|
487
|
+
doneDetail.hidden = false;
|
|
488
|
+
|
|
489
|
+
var count = document.createElement("span");
|
|
490
|
+
count.className = "countdown";
|
|
491
|
+
count.textContent = SECONDS;
|
|
492
|
+
var now = document.createElement("a");
|
|
493
|
+
now.className = "qr-url";
|
|
494
|
+
now.href = CHAT;
|
|
495
|
+
now.textContent = "Open it now";
|
|
496
|
+
doneDetail.textContent = "Opening the chat here too in ";
|
|
497
|
+
doneDetail.append(count, document.createTextNode(" \\u00b7 "), now);
|
|
498
|
+
|
|
499
|
+
var left = SECONDS;
|
|
500
|
+
var ticker = setInterval(function() {
|
|
501
|
+
left -= 1;
|
|
502
|
+
if (left <= 0) {
|
|
503
|
+
clearInterval(ticker);
|
|
504
|
+
window.location.assign(CHAT);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
count.textContent = left;
|
|
508
|
+
}, 1000);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function poll() {
|
|
512
|
+
try {
|
|
513
|
+
var r = await fetch("/setup/state?t=" + encodeURIComponent(TOKEN));
|
|
514
|
+
var s = await r.json();
|
|
515
|
+
if (s.paired) arrive();
|
|
516
|
+
} catch (e) {}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
poll();
|
|
520
|
+
timer = setInterval(poll, 1200);
|
|
521
|
+
})();
|
|
522
|
+
</script>
|
|
523
|
+
</body></html>"""
|
|
524
|
+
|
|
525
|
+
HANDOFF_HTML = """<!doctype html>
|
|
526
|
+
<html lang="en"><head>
|
|
527
|
+
<meta charset="utf-8">
|
|
528
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
529
|
+
<title>Phone linked — Call Walkie Talkie</title>
|
|
530
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
531
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
532
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
533
|
+
<style>__CSS__</style>
|
|
534
|
+
</head><body>
|
|
535
|
+
<div class="top">__BRAND__<span class="meta">Setup</span></div>
|
|
536
|
+
<div class="stage">
|
|
537
|
+
<div class="centered">
|
|
538
|
+
<span class="box done">__CHECK__</span>
|
|
539
|
+
<h1>Phone linked</h1>
|
|
540
|
+
<p>Opening cmux chat…</p>
|
|
541
|
+
</div>
|
|
542
|
+
</div>
|
|
543
|
+
<script>setTimeout(function(){ location.replace("__CHAT__"); }, 600);</script>
|
|
544
|
+
</body></html>"""
|
|
545
|
+
|
|
546
|
+
STALE_HTML = """<!doctype html>
|
|
547
|
+
<html lang="en"><head>
|
|
548
|
+
<meta charset="utf-8">
|
|
549
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
550
|
+
<title>Session expired — Call Walkie Talkie</title>
|
|
551
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
552
|
+
<style>__CSS__</style>
|
|
553
|
+
</head><body>
|
|
554
|
+
<div class="top">__BRAND__<span class="meta">Setup</span></div>
|
|
555
|
+
<div class="stage">
|
|
556
|
+
<div class="centered">
|
|
557
|
+
<h1>Session expired</h1>
|
|
558
|
+
<p>Run <code>npx callwalkietalkie</code> on your Mac and scan again.</p>
|
|
559
|
+
</div>
|
|
560
|
+
</div>
|
|
561
|
+
</body></html>"""
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _hostname():
|
|
565
|
+
return socket.gethostname().replace(".local", "")
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _fill(template, **values):
|
|
569
|
+
out = template
|
|
570
|
+
for key, val in values.items():
|
|
571
|
+
out = out.replace(f"__{key}__", str(val))
|
|
572
|
+
return out
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _qr_svg_data_uri(target_url, module_px=6, fill="#12202a", radius=0):
|
|
576
|
+
"""Real, scannable QR for target_url as an inline SVG data URI."""
|
|
577
|
+
qr = qrcode.QRCode(border=2, error_correction=ERROR_CORRECT_H)
|
|
578
|
+
qr.add_data(target_url)
|
|
579
|
+
qr.make(fit=True)
|
|
580
|
+
matrix = qr.get_matrix()
|
|
581
|
+
n = len(matrix)
|
|
582
|
+
size = n * module_px
|
|
583
|
+
rx = f' rx="{radius}"' if radius else ""
|
|
584
|
+
rects = "".join(
|
|
585
|
+
f'<rect x="{x * module_px}" y="{y * module_px}" '
|
|
586
|
+
f'width="{module_px}" height="{module_px}"{rx}/>'
|
|
587
|
+
for y, row in enumerate(matrix)
|
|
588
|
+
for x, on in enumerate(row)
|
|
589
|
+
if on
|
|
590
|
+
)
|
|
591
|
+
svg = (
|
|
592
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" '
|
|
593
|
+
f'viewBox="0 0 {size} {size}" shape-rendering="crispEdges">'
|
|
594
|
+
f'<rect width="{size}" height="{size}" fill="#fff"/>'
|
|
595
|
+
f'<g fill="{fill}">{rects}</g>'
|
|
596
|
+
f'</svg>'
|
|
597
|
+
)
|
|
598
|
+
return "data:image/svg+xml;base64," + base64.b64encode(svg.encode()).decode()
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def lan_ip():
|
|
602
|
+
try:
|
|
603
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
604
|
+
s.connect(("8.8.8.8", 80))
|
|
605
|
+
ip = s.getsockname()[0]
|
|
606
|
+
s.close()
|
|
607
|
+
return ip
|
|
608
|
+
except Exception:
|
|
609
|
+
return "127.0.0.1"
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
@app.route("/")
|
|
613
|
+
def root():
|
|
614
|
+
return Response(
|
|
615
|
+
f'<meta http-equiv="refresh" content="0;url=/setup?t={TOKEN}">',
|
|
616
|
+
mimetype="text/html",
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
@app.route("/setup")
|
|
621
|
+
def setup_page():
|
|
622
|
+
scan_url = _public_scan_url()
|
|
623
|
+
qr = f'<img src="{_qr_svg_data_uri(scan_url)}" width="148" height="148" alt="QR code">'
|
|
624
|
+
return _fill(
|
|
625
|
+
SETUP_HTML,
|
|
626
|
+
CSS=PAPER_CSS,
|
|
627
|
+
BRAND=_BRAND,
|
|
628
|
+
CHECK=_CHECK_SVG,
|
|
629
|
+
HOSTNAME=_hostname(),
|
|
630
|
+
TOKEN=TOKEN,
|
|
631
|
+
CODE=SESSION_CODE,
|
|
632
|
+
SCAN_URL=scan_url,
|
|
633
|
+
QR=qr,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
@app.route("/setup/state")
|
|
638
|
+
def setup_state():
|
|
639
|
+
ready = bool(SESSION_CODE) and (
|
|
640
|
+
not _relay_enabled() or _session_ready.is_set()
|
|
641
|
+
)
|
|
642
|
+
return jsonify(
|
|
643
|
+
{
|
|
644
|
+
"paired": _phone_arrived,
|
|
645
|
+
"code": SESSION_CODE or None,
|
|
646
|
+
"ready": ready,
|
|
647
|
+
"fingerprint": KEY_FINGERPRINT or None,
|
|
648
|
+
"pair_url": _public_pair_url() if SESSION_CODE else None,
|
|
649
|
+
}
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
@app.route("/p/<code>")
|
|
654
|
+
def phone_landed(code):
|
|
655
|
+
"""The listener. Reaching this route *is* the proof the phone scanned it."""
|
|
656
|
+
global _phone_arrived
|
|
657
|
+
if code != SESSION_CODE:
|
|
658
|
+
return _fill(STALE_HTML, CSS=PAPER_CSS, BRAND=_BRAND), 404
|
|
659
|
+
_phone_arrived = True
|
|
660
|
+
return _fill(
|
|
661
|
+
HANDOFF_HTML,
|
|
662
|
+
CSS=PAPER_CSS,
|
|
663
|
+
BRAND=_BRAND,
|
|
664
|
+
CHECK=_CHECK_SVG,
|
|
665
|
+
CHAT=f"{_api_base()}?t={TOKEN}",
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
# ---------------------------------------------------------------- agent (cmux chat)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
@app.route("/agent")
|
|
673
|
+
def agent_page():
|
|
674
|
+
ui_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "agent_ui.html")
|
|
675
|
+
html = open(ui_path, encoding="utf-8").read()
|
|
676
|
+
html = html.replace("__TOKEN_JSON__", json.dumps(TOKEN))
|
|
677
|
+
html = html.replace("__LANDING_JSON__", json.dumps(LANDING_URL))
|
|
678
|
+
html = html.replace("__API_BASE_JSON__", json.dumps(_api_base()))
|
|
679
|
+
return Response(
|
|
680
|
+
html,
|
|
681
|
+
mimetype="text/html",
|
|
682
|
+
headers={
|
|
683
|
+
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
|
684
|
+
"Pragma": "no-cache",
|
|
685
|
+
},
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
@app.route("/agent/status")
|
|
690
|
+
def agent_status():
|
|
691
|
+
try:
|
|
692
|
+
with _agent_lock:
|
|
693
|
+
session = _get_session()
|
|
694
|
+
session.refresh_target_meta()
|
|
695
|
+
t = session.target
|
|
696
|
+
sessions = []
|
|
697
|
+
try:
|
|
698
|
+
sessions = inventory(socket_path=session.socket_path)
|
|
699
|
+
except CmuxError:
|
|
700
|
+
pass
|
|
701
|
+
payload = {
|
|
702
|
+
"ok": True,
|
|
703
|
+
"app": "cmux",
|
|
704
|
+
"title": t.title,
|
|
705
|
+
"cwd": t.cwd,
|
|
706
|
+
"workspace": t.workspace_ref,
|
|
707
|
+
"surface": t.surface_ref,
|
|
708
|
+
"window": t.window_ref,
|
|
709
|
+
"socket": session.socket_path,
|
|
710
|
+
"sessions": sessions,
|
|
711
|
+
# Back-compat for older phone UI
|
|
712
|
+
"workspaces": [
|
|
713
|
+
{
|
|
714
|
+
"id": s.get("workspace_id"),
|
|
715
|
+
"ref": s.get("workspace"),
|
|
716
|
+
"title": s.get("title"),
|
|
717
|
+
"custom_title": None,
|
|
718
|
+
"current_directory": s.get("cwd"),
|
|
719
|
+
"selected": s.get("active"),
|
|
720
|
+
"surface": s.get("surface"),
|
|
721
|
+
}
|
|
722
|
+
for s in sessions
|
|
723
|
+
],
|
|
724
|
+
}
|
|
725
|
+
return jsonify(payload)
|
|
726
|
+
except KeyboardInterrupt:
|
|
727
|
+
raise
|
|
728
|
+
except BaseException as e:
|
|
729
|
+
return jsonify({"ok": False, "error": _agent_error_message(e)}), 503
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
@app.route("/agent/select", methods=["POST"])
|
|
733
|
+
def agent_select():
|
|
734
|
+
body = request.get_json(force=True, silent=True) or {}
|
|
735
|
+
workspace = (body.get("workspace") or "").strip() or None
|
|
736
|
+
surface = (body.get("surface") or "").strip() or None
|
|
737
|
+
window = (body.get("window") or "").strip() or None
|
|
738
|
+
try:
|
|
739
|
+
with _agent_lock:
|
|
740
|
+
session = _get_session(
|
|
741
|
+
workspace=workspace, surface=surface, window=window, force=True
|
|
742
|
+
)
|
|
743
|
+
session.focus()
|
|
744
|
+
t = session.target
|
|
745
|
+
return jsonify(
|
|
746
|
+
{
|
|
747
|
+
"ok": True,
|
|
748
|
+
"workspace": t.workspace_ref,
|
|
749
|
+
"surface": t.surface_ref,
|
|
750
|
+
"window": t.window_ref,
|
|
751
|
+
"title": t.title,
|
|
752
|
+
"cwd": t.cwd,
|
|
753
|
+
}
|
|
754
|
+
)
|
|
755
|
+
except KeyboardInterrupt:
|
|
756
|
+
raise
|
|
757
|
+
except BaseException as e:
|
|
758
|
+
return jsonify({"ok": False, "error": _agent_error_message(e)}), 503
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
@app.route("/agent/transcript")
|
|
762
|
+
def agent_transcript():
|
|
763
|
+
try:
|
|
764
|
+
with _agent_lock:
|
|
765
|
+
session = _get_session()
|
|
766
|
+
msgs = session.transcript()
|
|
767
|
+
payload = {
|
|
768
|
+
"ok": True,
|
|
769
|
+
"busy": session.busy(),
|
|
770
|
+
"messages": [
|
|
771
|
+
{"role": m.role, "text": m.text, "kind": m.kind} for m in msgs
|
|
772
|
+
],
|
|
773
|
+
}
|
|
774
|
+
return jsonify(payload)
|
|
775
|
+
except KeyboardInterrupt:
|
|
776
|
+
raise
|
|
777
|
+
except BaseException as e:
|
|
778
|
+
return jsonify({"ok": False, "error": _agent_error_message(e)}), 503
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
@app.route("/agent/send", methods=["POST"])
|
|
782
|
+
def agent_send():
|
|
783
|
+
body = request.get_json(force=True, silent=True) or {}
|
|
784
|
+
text = (body.get("text") or "").strip()
|
|
785
|
+
if not text:
|
|
786
|
+
return jsonify({"ok": False, "error": "empty message"}), 400
|
|
787
|
+
try:
|
|
788
|
+
with _agent_lock:
|
|
789
|
+
session = _get_session()
|
|
790
|
+
session.send(text)
|
|
791
|
+
if _session_surface:
|
|
792
|
+
_sent_by_surface[_session_surface] = list(session._sent)
|
|
793
|
+
return jsonify({"ok": True})
|
|
794
|
+
except KeyboardInterrupt:
|
|
795
|
+
raise
|
|
796
|
+
except BaseException as e:
|
|
797
|
+
return jsonify({"ok": False, "error": _agent_error_message(e)}), 503
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
@app.route("/agent/interrupt", methods=["POST"])
|
|
801
|
+
def agent_interrupt():
|
|
802
|
+
try:
|
|
803
|
+
with _agent_lock:
|
|
804
|
+
session = _get_session()
|
|
805
|
+
session.interrupt()
|
|
806
|
+
return jsonify({"ok": True})
|
|
807
|
+
except KeyboardInterrupt:
|
|
808
|
+
raise
|
|
809
|
+
except BaseException as e:
|
|
810
|
+
return jsonify({"ok": False, "error": _agent_error_message(e)}), 503
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _caffeinate_active():
|
|
814
|
+
return _caffeinate_proc is not None and _caffeinate_proc.poll() is None
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _stop_caffeinate():
|
|
818
|
+
global _caffeinate_proc
|
|
819
|
+
proc = _caffeinate_proc
|
|
820
|
+
_caffeinate_proc = None
|
|
821
|
+
if proc is None or proc.poll() is not None:
|
|
822
|
+
return
|
|
823
|
+
try:
|
|
824
|
+
proc.terminate()
|
|
825
|
+
proc.wait(timeout=1)
|
|
826
|
+
except Exception:
|
|
827
|
+
try:
|
|
828
|
+
proc.kill()
|
|
829
|
+
except Exception:
|
|
830
|
+
pass
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _start_caffeinate(seconds=None):
|
|
834
|
+
global _caffeinate_proc, _caffeinate_seconds, _caffeinate_atexit
|
|
835
|
+
if seconds is not None:
|
|
836
|
+
_caffeinate_seconds = int(seconds)
|
|
837
|
+
if _caffeinate_seconds <= 0:
|
|
838
|
+
_stop_caffeinate()
|
|
839
|
+
return False
|
|
840
|
+
_stop_caffeinate()
|
|
841
|
+
try:
|
|
842
|
+
_caffeinate_proc = subprocess.Popen(
|
|
843
|
+
["caffeinate", "-dims", "-t", str(_caffeinate_seconds)],
|
|
844
|
+
stdout=subprocess.DEVNULL,
|
|
845
|
+
stderr=subprocess.DEVNULL,
|
|
846
|
+
)
|
|
847
|
+
except FileNotFoundError:
|
|
848
|
+
_caffeinate_proc = None
|
|
849
|
+
return False
|
|
850
|
+
if not _caffeinate_atexit:
|
|
851
|
+
atexit.register(_stop_caffeinate)
|
|
852
|
+
_caffeinate_atexit = True
|
|
853
|
+
return True
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
@app.route("/agent/caffeinate", methods=["GET", "POST"])
|
|
857
|
+
def agent_caffeinate():
|
|
858
|
+
if request.method == "POST":
|
|
859
|
+
body = request.get_json(force=True, silent=True) or {}
|
|
860
|
+
want = body.get("on")
|
|
861
|
+
if want is None:
|
|
862
|
+
want = not _caffeinate_active()
|
|
863
|
+
if want:
|
|
864
|
+
ok = _start_caffeinate()
|
|
865
|
+
if not ok and _caffeinate_seconds > 0:
|
|
866
|
+
return jsonify(ok=False, on=False, error="caffeinate unavailable"), 503
|
|
867
|
+
else:
|
|
868
|
+
_stop_caffeinate()
|
|
869
|
+
return jsonify(ok=True, on=_caffeinate_active(), seconds=_caffeinate_seconds)
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
@app.route("/agent/shutdown", methods=["POST"])
|
|
873
|
+
def agent_shutdown():
|
|
874
|
+
def _die():
|
|
875
|
+
time.sleep(0.6)
|
|
876
|
+
_stop_caffeinate()
|
|
877
|
+
os._exit(0)
|
|
878
|
+
|
|
879
|
+
threading.Thread(target=_die, daemon=True).start()
|
|
880
|
+
return jsonify({"ok": True})
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
# ---------------------------------------------------------------- boot
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _terminal_hyperlink(text, url):
|
|
887
|
+
return f"\033]8;;{url}\033\\{text}\033]8;;\033\\"
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _open_browser_later(url, delay=0.75):
|
|
891
|
+
def _go():
|
|
892
|
+
time.sleep(delay)
|
|
893
|
+
try:
|
|
894
|
+
webbrowser.open(url)
|
|
895
|
+
except Exception:
|
|
896
|
+
pass
|
|
897
|
+
|
|
898
|
+
threading.Thread(target=_go, daemon=True).start()
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def main():
|
|
902
|
+
parser = argparse.ArgumentParser(description="callwalkietalkie — phone ↔ cmux")
|
|
903
|
+
parser.add_argument("--no-open", action="store_true")
|
|
904
|
+
parser.add_argument("--port", type=int, default=PORT)
|
|
905
|
+
args = parser.parse_args()
|
|
906
|
+
port = args.port
|
|
907
|
+
|
|
908
|
+
# Fail fast with a clear message if cmux isn't up.
|
|
909
|
+
try:
|
|
910
|
+
info = cmux_ping()
|
|
911
|
+
print(f" cmux ok · {info['socket_path']}")
|
|
912
|
+
except CmuxError as e:
|
|
913
|
+
print(f"\n {e}\n", file=sys.stderr)
|
|
914
|
+
sys.exit(1)
|
|
915
|
+
|
|
916
|
+
setup_url = f"http://127.0.0.1:{port}/setup?t={TOKEN}"
|
|
917
|
+
|
|
918
|
+
if _relay_enabled():
|
|
919
|
+
if not MACHINE_KEY:
|
|
920
|
+
print(
|
|
921
|
+
"\n Missing machine key. Run via: npx callwalkietalkie\n",
|
|
922
|
+
file=sys.stderr,
|
|
923
|
+
)
|
|
924
|
+
sys.exit(1)
|
|
925
|
+
threading.Thread(target=_tunnel_loop, daemon=True).start()
|
|
926
|
+
if not _session_ready.wait(timeout=25):
|
|
927
|
+
print(
|
|
928
|
+
"\n Relay did not issue a session. Check callwalkietalkie.com / network.\n",
|
|
929
|
+
file=sys.stderr,
|
|
930
|
+
)
|
|
931
|
+
sys.exit(1)
|
|
932
|
+
else:
|
|
933
|
+
_bootstrap_session()
|
|
934
|
+
|
|
935
|
+
pair_url = _public_pair_url()
|
|
936
|
+
phone_hint = _public_scan_url()
|
|
937
|
+
|
|
938
|
+
print(f" Scan the QR here: {_terminal_hyperlink(pair_url, pair_url)}")
|
|
939
|
+
print(f" Phone path: {phone_hint}")
|
|
940
|
+
print(f" Session {SESSION_CODE}")
|
|
941
|
+
if KEY_FINGERPRINT:
|
|
942
|
+
print(f" Key {KEY_FINGERPRINT}")
|
|
943
|
+
|
|
944
|
+
def _on_signal(signum, frame):
|
|
945
|
+
_stop_caffeinate()
|
|
946
|
+
sys.exit(0)
|
|
947
|
+
|
|
948
|
+
signal.signal(signal.SIGINT, _on_signal)
|
|
949
|
+
signal.signal(signal.SIGTERM, _on_signal)
|
|
950
|
+
|
|
951
|
+
open_url = pair_url if _relay_enabled() else setup_url
|
|
952
|
+
if not args.no_open:
|
|
953
|
+
_open_browser_later(open_url)
|
|
954
|
+
|
|
955
|
+
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
|
956
|
+
app.run(host="0.0.0.0", port=port, threaded=True, use_reloader=False)
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
if __name__ == "__main__":
|
|
960
|
+
main()
|