codebee 0.1.6 → 0.1.7

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.
@@ -1,303 +1,310 @@
1
- # -*- coding: utf-8 -*-
2
- """手机/远程访问:访问令牌 + 多端控制权锁 + 服务地址探测。
3
-
4
- 令牌:首次启动生成,落盘 data/remote.json。本机(127.0.0.1)请求豁免,
5
- 非 loopback 请求必须带令牌(?token= 或 X-CodeBee-Token 头)。
6
- 控制权:同一时刻只有一台设备能执行写操作。空闲自动接管、45s 无心跳自动释放,
7
- 可强制抢夺。纯内存状态,重启即清空。
8
- """
9
- from __future__ import annotations
10
-
11
- import json
12
- import re
13
- import secrets
14
- import shutil
15
- import subprocess
16
- import threading
17
- import time
18
-
19
- from . import paths
20
-
21
- # ---------------------------------------------------------------- 访问令牌
22
- _TOK_LOCK = threading.Lock()
23
- _TOKEN = ""
24
-
25
-
26
- def token() -> str:
27
- """读取(必要时生成)访问令牌。"""
28
- global _TOKEN
29
- if _TOKEN:
30
- return _TOKEN
31
- with _TOK_LOCK:
32
- if _TOKEN:
33
- return _TOKEN
34
- p = paths.DATA_DIR / "remote.json"
35
- try:
36
- data = json.loads(p.read_text(encoding="utf-8"))
37
- _TOKEN = str(data.get("token") or "")
38
- except Exception:
39
- _TOKEN = ""
40
- if not _TOKEN:
41
- _TOKEN = secrets.token_hex(4) # 8 位十六进制,手机好输
42
- try:
43
- p.parent.mkdir(parents=True, exist_ok=True)
44
- p.write_text(json.dumps(
45
- {"token": _TOKEN, "created": time.strftime("%Y-%m-%d %H:%M:%S")},
46
- ensure_ascii=False, indent=2), encoding="utf-8")
47
- except Exception:
48
- pass # 落盘失败也照常工作(内存令牌,重启更换)
49
- return _TOKEN
50
-
51
-
52
- # ---------------------------------------------------------------- 反向代理感知
53
- # Cloudflare Tunnel / frp 等都从本机回源:若不感知代理,公网请求一律被当成
54
- # 127.0.0.1 而豁免令牌——等于把控制台裸奔到公网。--trusted-proxy / TUTTI_TRUST_PROXY
55
- # 开启后:带转发头(CF-Connecting-IP / X-Forwarded-For)的请求视为经代理进来的
56
- # 远程请求,必须带令牌;不带转发头的 loopback 仍是真本机(浏览器直接开 localhost)。
57
- # 安全性:Cloudflare 边缘强制注入/覆盖 CF-Connecting-IP,外部无法伪造透传;
58
- # 而能直连本机端口的攻击者伪造转发头只会让自己从"本机豁免"变成"必须带令牌"。
59
- _TRUST_PROXY = False
60
- PUBLIC_URL = "" # --public-url / 快速隧道:扫码弹框优先展示的公网地址
61
- PUBLIC_URL_KIND = "" # quick(trycloudflare 临时,重启变)| fixed(自有域名)
62
-
63
-
64
- def set_trusted_proxy(on: bool, public_url: str = ""):
65
- global _TRUST_PROXY, PUBLIC_URL
66
- _TRUST_PROXY = bool(on)
67
- if public_url:
68
- PUBLIC_URL = str(public_url).rstrip("/")
69
-
70
-
71
- # ---------------------------------------------------------------- 快速隧道(trycloudflare)
72
- # 安装即公网:本机装了 cloudflared 就自动开一条 Cloudflare 快速隧道,
73
- # 拿到随机 *.trycloudflare.com 地址——零账号、零域名、零配置。
74
- # 安全强绑定:开隧道必然同时开启 trusted-proxy(否则回源 loopback 豁免=裸奔)。
75
- # 代价:每次重启地址会变(手机重新扫码即可);要固定域名见 README 公网章节。
76
- _QUICK_PROC = None # cloudflared 子进程,随主服务退出
77
-
78
-
79
- def cloudflared_exe() -> str:
80
- import os
81
- exe = shutil.which("cloudflared")
82
- if exe:
83
- return exe
84
- guess = os.path.join(os.environ.get("ProgramFiles(x86)", ""), "cloudflared", "cloudflared.exe")
85
- return guess if os.path.isfile(guess) else ""
86
-
87
-
88
- def has_local_creds() -> bool:
89
- """~/.cloudflared 下有隧道凭据(用户已玩过 named tunnel)。
90
-
91
- 实测(2026-09):本机存在凭据时 cloudflared 会把 quick tunnel 降级为
92
- named 模式运行,随机域名恒 404;此时应走固定域名路径而不是 quick。
93
- """
94
- import os
95
- d = os.path.join(os.path.expanduser("~"), ".cloudflared")
96
- try:
97
- return any(f.endswith(".json") for f in os.listdir(d))
98
- except Exception:
99
- return False
100
-
101
-
102
- def start_quick_tunnel(port: int, on_url) -> bool:
103
- """后台起快速隧道,解析到随机 URL 后回调 on_url(url)(如在主线程打印+推送)。
104
-
105
- 返回 False 表示没装 cloudflared 或启动失败,调用方给出提示。
106
- """
107
- global _QUICK_PROC
108
- exe = cloudflared_exe()
109
- if not exe or not port:
110
- return False
111
- import subprocess
112
- flags = 0x08000000 if hasattr(subprocess, "CREATE_NO_WINDOW") else 0 # CREATE_NO_WINDOW
113
- try:
114
- _QUICK_PROC = subprocess.Popen(
115
- [exe, "tunnel", "--url", "http://localhost:%d" % port, "--no-autoupdate"],
116
- stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
117
- creationflags=flags, text=True, encoding="utf-8", errors="replace")
118
- except Exception:
119
- _QUICK_PROC = None
120
- return False
121
- import threading
122
-
123
- def _watch():
124
- global PUBLIC_URL, PUBLIC_URL_KIND
125
- deadline = time.time() + 25
126
- found = False
127
- try:
128
- for line in _QUICK_PROC.stdout:
129
- if not found:
130
- m = re.search(r"https://[a-z0-9-]+\.trycloudflare\.com", line or "")
131
- if m:
132
- set_trusted_proxy(True) # 公网暴露 ⇆ 强制反代感知,防回源豁免
133
- PUBLIC_URL = m.group(0)
134
- PUBLIC_URL_KIND = "quick"
135
- on_url(PUBLIC_URL)
136
- found = True
137
- # break:必须继续消费日志,否则 cloudflared 写满管道
138
- # 缓冲后会整体阻塞,边缘连接注册无法完成(实测 530)
139
- if time.time() > deadline and not found:
140
- break
141
- except Exception:
142
- pass
143
-
144
- threading.Thread(target=_watch, daemon=True).start()
145
- return True
146
-
147
-
148
- def stop_quick_tunnel():
149
- global _QUICK_PROC
150
- if _QUICK_PROC:
151
- try:
152
- _QUICK_PROC.terminate()
153
- except Exception:
154
- pass
155
- _QUICK_PROC = None
156
-
157
-
158
- def effective_ip(socket_ip: str, forwarded: str) -> str:
159
- """信任代理时从转发头取真实客户端 IP(第一跳),否则用 socket 地址。"""
160
- if _TRUST_PROXY and forwarded:
161
- first = forwarded.split(",")[0].strip()
162
- if first:
163
- return first
164
- return socket_ip
165
-
166
-
167
- def request_authed(client_ip: str, forwarded: str, query_token: str, header_token: str) -> bool:
168
- """本机豁免;远程(或经代理回源)请求必须带正确令牌。"""
169
- loopback = client_ip in ("127.0.0.1", "::1")
170
- if loopback and not (_TRUST_PROXY and forwarded):
171
- return True # 真本机(trust_proxy 下不带转发头的 loopback 也算)
172
- tok = token()
173
- if not tok:
174
- return True
175
- return secrets.compare_digest(str(query_token or ""), tok) or \
176
- secrets.compare_digest(str(header_token or ""), tok)
177
-
178
-
179
- # ---------------------------------------------------------------- 控制权锁
180
- CTRL_TTL = 45.0 # 秒;持锁设备停止心跳后自动释放
181
- _CTRL = {"client_id": "", "name": "", "expires_at": 0.0}
182
- # RLock:acquire/release 持锁期间会调 control_view,它也要拿同一把锁
183
- _CTRL_LOCK = threading.RLock()
184
-
185
-
186
- def _live_ctrl():
187
- """返回未过期的持锁信息;过期则视为空闲(惰性过期,无需定时线程)。"""
188
- if _CTRL["client_id"] and time.time() > _CTRL["expires_at"]:
189
- _CTRL.update({"client_id": "", "name": "", "expires_at": 0.0})
190
- return _CTRL
191
-
192
-
193
- def control_view(client_id: str = "") -> dict:
194
- """对外的控制权状态:free / held(mine 标记是否是请求方自己持有)。
195
-
196
- expires_in 10s 桶化:SSE 靠对比该 dict 判断"控制权是否变了"
197
- 精确到秒的倒计时会造成每秒一次假变化 → 全量推送。
198
- """
199
- with _CTRL_LOCK:
200
- c = _live_ctrl()
201
- if not c["client_id"]:
202
- return {"mode": "free", "mine": False}
203
- return {"mode": "held", "mine": bool(client_id) and c["client_id"] == client_id,
204
- "holder": c["name"] or "其他设备",
205
- "expires_in": max(10, int(c["expires_at"] - time.time()) // 10 * 10)}
206
-
207
-
208
- def acquire(client_id: str, name: str, force: bool = False):
209
- """接管控制权。已持有则顺延心跳;空闲直接拿到;他人持有时除非 force 否则拒绝。
210
-
211
- 返回 (ok, control_view)。
212
- """
213
- client_id = str(client_id or "")
214
- if not client_id:
215
- client_id = "anon-" + secrets.token_hex(4)
216
- with _CTRL_LOCK:
217
- c = _live_ctrl()
218
- if c["client_id"] == client_id:
219
- c["expires_at"] = time.time() + CTRL_TTL
220
- return True, control_view(client_id)
221
- if c["client_id"] and not force:
222
- return False, control_view(client_id)
223
- _CTRL.update({"client_id": client_id,
224
- "name": _safe_name(name) or "其他设备",
225
- "expires_at": time.time() + CTRL_TTL})
226
- return True, control_view(client_id)
227
-
228
-
229
- def release(client_id: str) -> dict:
230
- with _CTRL_LOCK:
231
- if client_id and _CTRL["client_id"] == client_id:
232
- _CTRL.update({"client_id": "", "name": "", "expires_at": 0.0})
233
- return control_view(client_id)
234
-
235
-
236
- def heartbeat(client_id: str):
237
- """持锁续期。返回 (是否仍持有, control_view)。"""
238
- ok, view = acquire(client_id, "")
239
- return ok, view
240
-
241
-
242
- def _safe_name(s) -> str:
243
- return re.sub(r"\s+", " ", str(s or "")).strip()[:24]
244
-
245
-
246
- # ---------------------------------------------------------------- 地址探测
247
- _TS_CACHE = {"ip": "", "ts": 0.0} # tailscale CLI 调用有开销,30s 缓存
248
-
249
-
250
- def lan_ip() -> str:
251
- """本机局域网 IP(UDP connect 只选路由不发包)。"""
252
- import socket
253
- s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
254
- try:
255
- s.connect(("223.5.5.5", 80))
256
- return s.getsockname()[0]
257
- except Exception:
258
- return ""
259
- finally:
260
- s.close()
261
-
262
-
263
- def tailscale_ip(max_age: float = 30.0) -> str:
264
- """Tailscale 虚拟网 IP;未安装/未启动返回空。结果缓存 30s。"""
265
- if max_age > 0 and time.time() - _TS_CACHE["ts"] < max_age:
266
- return _TS_CACHE["ip"]
267
- ip = _tailscale_ip_once()
268
- _TS_CACHE.update({"ip": ip, "ts": time.time()})
269
- return ip
270
-
271
-
272
- def _tailscale_ip_once() -> str:
273
- exe = shutil.which("tailscale")
274
- if not exe:
275
- return ""
276
- try:
277
- out = subprocess.run([exe, "ip", "-4"], capture_output=True,
278
- text=True, timeout=5)
279
- for line in (out.stdout or "").splitlines():
280
- ip = line.strip()
281
- if re.match(r"^\d+\.\d+\.\d+\.\d+$", ip):
282
- return ip
283
- except Exception:
284
- pass
285
- return ""
286
-
287
-
288
- def build_connect_urls(port: int) -> list:
289
- """手机扫码可用的连接地址,按优先级排序:公网域名 > Tailscale > 局域网。"""
290
- urls = []
291
- if PUBLIC_URL:
292
- label = ("公网 · 任何网络(重启会变,连不上重新扫码)" if PUBLIC_URL_KIND == "quick"
293
- else "公网 · 任何网络")
294
- urls.append({"label": label, "url": "%s/?token=%s" % (PUBLIC_URL, token())})
295
- ts = tailscale_ip()
296
- if ts:
297
- urls.append({"label": "Tailscale · 外网随时随地",
298
- "url": "http://%s:%d/?token=%s" % (ts, port, token())})
299
- lan = lan_ip()
300
- if lan:
301
- urls.append({"label": "局域网 · 同一 WiFi",
302
- "url": "http://%s:%d/?token=%s" % (lan, port, token())})
303
- return urls
1
+ # -*- coding: utf-8 -*-
2
+ """手机/远程访问:访问令牌 + 多端控制权锁 + 服务地址探测。
3
+
4
+ 令牌:首次启动生成,落盘 data/remote.json。本机(127.0.0.1)请求豁免,
5
+ 非 loopback 请求必须带令牌(?token= 或 X-CodeBee-Token 头)。
6
+ 控制权:同一时刻只有一台设备能执行写操作。空闲自动接管、45s 无心跳自动释放,
7
+ 可强制抢夺。纯内存状态,重启即清空。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ import secrets
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import threading
18
+ import time
19
+
20
+ from . import paths
21
+
22
+ # ---------------------------------------------------------------- 访问令牌
23
+ _TOK_LOCK = threading.Lock()
24
+ _TOKEN = ""
25
+
26
+
27
+ def token() -> str:
28
+ """读取(必要时生成)访问令牌。"""
29
+ global _TOKEN
30
+ if _TOKEN:
31
+ return _TOKEN
32
+ with _TOK_LOCK:
33
+ if _TOKEN:
34
+ return _TOKEN
35
+ p = paths.DATA_DIR / "remote.json"
36
+ try:
37
+ data = json.loads(p.read_text(encoding="utf-8"))
38
+ _TOKEN = str(data.get("token") or "")
39
+ except Exception:
40
+ _TOKEN = ""
41
+ if not _TOKEN:
42
+ _TOKEN = secrets.token_hex(4) # 8 位十六进制,手机好输
43
+ try:
44
+ p.parent.mkdir(parents=True, exist_ok=True)
45
+ p.write_text(json.dumps(
46
+ {"token": _TOKEN, "created": time.strftime("%Y-%m-%d %H:%M:%S")},
47
+ ensure_ascii=False, indent=2), encoding="utf-8")
48
+ except Exception:
49
+ pass # 落盘失败也照常工作(内存令牌,重启更换)
50
+ return _TOKEN
51
+
52
+
53
+ # ---------------------------------------------------------------- 反向代理感知
54
+ # Cloudflare Tunnel / frp 等都从本机回源:若不感知代理,公网请求一律被当成
55
+ # 127.0.0.1 而豁免令牌——等于把控制台裸奔到公网。--trusted-proxy / TUTTI_TRUST_PROXY
56
+ # 开启后:带转发头(CF-Connecting-IP / X-Forwarded-For)的请求视为经代理进来的
57
+ # 远程请求,必须带令牌;不带转发头的 loopback 仍是真本机(浏览器直接开 localhost)。
58
+ # 安全性:Cloudflare 边缘强制注入/覆盖 CF-Connecting-IP,外部无法伪造透传;
59
+ # 而能直连本机端口的攻击者伪造转发头只会让自己从"本机豁免"变成"必须带令牌"。
60
+ _TRUST_PROXY = False
61
+ PUBLIC_URL = "" # --public-url / 快速隧道:扫码弹框优先展示的公网地址
62
+ PUBLIC_URL_KIND = "" # quick(trycloudflare 临时,重启变)| fixed(自有域名)
63
+
64
+
65
+ def set_trusted_proxy(on: bool, public_url: str = ""):
66
+ global _TRUST_PROXY, PUBLIC_URL
67
+ _TRUST_PROXY = bool(on)
68
+ if public_url:
69
+ PUBLIC_URL = str(public_url).rstrip("/")
70
+
71
+
72
+ # ---------------------------------------------------------------- 快速隧道(trycloudflare)
73
+ # 安装即公网:本机装了 cloudflared 就自动开一条 Cloudflare 快速隧道,
74
+ # 拿到随机 *.trycloudflare.com 地址——零账号、零域名、零配置。
75
+ # 安全强绑定:开隧道必然同时开启 trusted-proxy(否则回源 loopback 豁免=裸奔)。
76
+ # 代价:每次重启地址会变(手机重新扫码即可);要固定域名见 README 公网章节。
77
+ _QUICK_PROC = None # cloudflared 子进程,随主服务退出
78
+
79
+
80
+ def cloudflared_exe() -> str:
81
+ import os
82
+ exe = shutil.which("cloudflared")
83
+ if exe:
84
+ return exe
85
+ if sys.platform == "darwin":
86
+ # Homebrew(Apple Silicon /usr/local 旧装)不在 PATH 时的常见落点
87
+ for guess in ("/opt/homebrew/bin/cloudflared", "/usr/local/bin/cloudflared"):
88
+ if os.path.isfile(guess):
89
+ return guess
90
+ return ""
91
+ guess = os.path.join(os.environ.get("ProgramFiles(x86)", ""), "cloudflared", "cloudflared.exe")
92
+ return guess if os.path.isfile(guess) else ""
93
+
94
+
95
+ def has_local_creds() -> bool:
96
+ """~/.cloudflared 下有隧道凭据(用户已玩过 named tunnel)。
97
+
98
+ 实测(2026-09):本机存在凭据时 cloudflared 会把 quick tunnel 降级为
99
+ named 模式运行,随机域名恒 404;此时应走固定域名路径而不是 quick。
100
+ """
101
+ import os
102
+ d = os.path.join(os.path.expanduser("~"), ".cloudflared")
103
+ try:
104
+ return any(f.endswith(".json") for f in os.listdir(d))
105
+ except Exception:
106
+ return False
107
+
108
+
109
+ def start_quick_tunnel(port: int, on_url) -> bool:
110
+ """后台起快速隧道,解析到随机 URL 后回调 on_url(url)(如在主线程打印+推送)。
111
+
112
+ 返回 False 表示没装 cloudflared 或启动失败,调用方给出提示。
113
+ """
114
+ global _QUICK_PROC
115
+ exe = cloudflared_exe()
116
+ if not exe or not port:
117
+ return False
118
+ import subprocess
119
+ flags = 0x08000000 if hasattr(subprocess, "CREATE_NO_WINDOW") else 0 # CREATE_NO_WINDOW
120
+ try:
121
+ _QUICK_PROC = subprocess.Popen(
122
+ [exe, "tunnel", "--url", "http://localhost:%d" % port, "--no-autoupdate"],
123
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
124
+ creationflags=flags, text=True, encoding="utf-8", errors="replace")
125
+ except Exception:
126
+ _QUICK_PROC = None
127
+ return False
128
+ import threading
129
+
130
+ def _watch():
131
+ global PUBLIC_URL, PUBLIC_URL_KIND
132
+ deadline = time.time() + 25
133
+ found = False
134
+ try:
135
+ for line in _QUICK_PROC.stdout:
136
+ if not found:
137
+ m = re.search(r"https://[a-z0-9-]+\.trycloudflare\.com", line or "")
138
+ if m:
139
+ set_trusted_proxy(True) # 公网暴露 强制反代感知,防回源豁免
140
+ PUBLIC_URL = m.group(0)
141
+ PUBLIC_URL_KIND = "quick"
142
+ on_url(PUBLIC_URL)
143
+ found = True
144
+ # 不 break:必须继续消费日志,否则 cloudflared 写满管道
145
+ # 缓冲后会整体阻塞,边缘连接注册无法完成(实测 530)
146
+ if time.time() > deadline and not found:
147
+ break
148
+ except Exception:
149
+ pass
150
+
151
+ threading.Thread(target=_watch, daemon=True).start()
152
+ return True
153
+
154
+
155
+ def stop_quick_tunnel():
156
+ global _QUICK_PROC
157
+ if _QUICK_PROC:
158
+ try:
159
+ _QUICK_PROC.terminate()
160
+ except Exception:
161
+ pass
162
+ _QUICK_PROC = None
163
+
164
+
165
+ def effective_ip(socket_ip: str, forwarded: str) -> str:
166
+ """信任代理时从转发头取真实客户端 IP(第一跳),否则用 socket 地址。"""
167
+ if _TRUST_PROXY and forwarded:
168
+ first = forwarded.split(",")[0].strip()
169
+ if first:
170
+ return first
171
+ return socket_ip
172
+
173
+
174
+ def request_authed(client_ip: str, forwarded: str, query_token: str, header_token: str) -> bool:
175
+ """本机豁免;远程(或经代理回源)请求必须带正确令牌。"""
176
+ loopback = client_ip in ("127.0.0.1", "::1")
177
+ if loopback and not (_TRUST_PROXY and forwarded):
178
+ return True # 真本机(trust_proxy 下不带转发头的 loopback 也算)
179
+ tok = token()
180
+ if not tok:
181
+ return True
182
+ return secrets.compare_digest(str(query_token or ""), tok) or \
183
+ secrets.compare_digest(str(header_token or ""), tok)
184
+
185
+
186
+ # ---------------------------------------------------------------- 控制权锁
187
+ CTRL_TTL = 45.0 # 秒;持锁设备停止心跳后自动释放
188
+ _CTRL = {"client_id": "", "name": "", "expires_at": 0.0}
189
+ # RLock:acquire/release 持锁期间会调 control_view,它也要拿同一把锁
190
+ _CTRL_LOCK = threading.RLock()
191
+
192
+
193
+ def _live_ctrl():
194
+ """返回未过期的持锁信息;过期则视为空闲(惰性过期,无需定时线程)。"""
195
+ if _CTRL["client_id"] and time.time() > _CTRL["expires_at"]:
196
+ _CTRL.update({"client_id": "", "name": "", "expires_at": 0.0})
197
+ return _CTRL
198
+
199
+
200
+ def control_view(client_id: str = "") -> dict:
201
+ """对外的控制权状态:free / held(mine 标记是否是请求方自己持有)。
202
+
203
+ expires_in 10s 桶化:SSE 靠对比该 dict 判断"控制权是否变了"
204
+ 精确到秒的倒计时会造成每秒一次假变化 全量推送。
205
+ """
206
+ with _CTRL_LOCK:
207
+ c = _live_ctrl()
208
+ if not c["client_id"]:
209
+ return {"mode": "free", "mine": False}
210
+ return {"mode": "held", "mine": bool(client_id) and c["client_id"] == client_id,
211
+ "holder": c["name"] or "其他设备",
212
+ "expires_in": max(10, int(c["expires_at"] - time.time()) // 10 * 10)}
213
+
214
+
215
+ def acquire(client_id: str, name: str, force: bool = False):
216
+ """接管控制权。已持有则顺延心跳;空闲直接拿到;他人持有时除非 force 否则拒绝。
217
+
218
+ 返回 (ok, control_view)。
219
+ """
220
+ client_id = str(client_id or "")
221
+ if not client_id:
222
+ client_id = "anon-" + secrets.token_hex(4)
223
+ with _CTRL_LOCK:
224
+ c = _live_ctrl()
225
+ if c["client_id"] == client_id:
226
+ c["expires_at"] = time.time() + CTRL_TTL
227
+ return True, control_view(client_id)
228
+ if c["client_id"] and not force:
229
+ return False, control_view(client_id)
230
+ _CTRL.update({"client_id": client_id,
231
+ "name": _safe_name(name) or "其他设备",
232
+ "expires_at": time.time() + CTRL_TTL})
233
+ return True, control_view(client_id)
234
+
235
+
236
+ def release(client_id: str) -> dict:
237
+ with _CTRL_LOCK:
238
+ if client_id and _CTRL["client_id"] == client_id:
239
+ _CTRL.update({"client_id": "", "name": "", "expires_at": 0.0})
240
+ return control_view(client_id)
241
+
242
+
243
+ def heartbeat(client_id: str):
244
+ """持锁续期。返回 (是否仍持有, control_view)。"""
245
+ ok, view = acquire(client_id, "")
246
+ return ok, view
247
+
248
+
249
+ def _safe_name(s) -> str:
250
+ return re.sub(r"\s+", " ", str(s or "")).strip()[:24]
251
+
252
+
253
+ # ---------------------------------------------------------------- 地址探测
254
+ _TS_CACHE = {"ip": "", "ts": 0.0} # tailscale CLI 调用有开销,30s 缓存
255
+
256
+
257
+ def lan_ip() -> str:
258
+ """本机局域网 IP(UDP connect 只选路由不发包)。"""
259
+ import socket
260
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
261
+ try:
262
+ s.connect(("223.5.5.5", 80))
263
+ return s.getsockname()[0]
264
+ except Exception:
265
+ return ""
266
+ finally:
267
+ s.close()
268
+
269
+
270
+ def tailscale_ip(max_age: float = 30.0) -> str:
271
+ """Tailscale 虚拟网 IP;未安装/未启动返回空。结果缓存 30s。"""
272
+ if max_age > 0 and time.time() - _TS_CACHE["ts"] < max_age:
273
+ return _TS_CACHE["ip"]
274
+ ip = _tailscale_ip_once()
275
+ _TS_CACHE.update({"ip": ip, "ts": time.time()})
276
+ return ip
277
+
278
+
279
+ def _tailscale_ip_once() -> str:
280
+ exe = shutil.which("tailscale")
281
+ if not exe:
282
+ return ""
283
+ try:
284
+ out = subprocess.run([exe, "ip", "-4"], capture_output=True,
285
+ text=True, timeout=5)
286
+ for line in (out.stdout or "").splitlines():
287
+ ip = line.strip()
288
+ if re.match(r"^\d+\.\d+\.\d+\.\d+$", ip):
289
+ return ip
290
+ except Exception:
291
+ pass
292
+ return ""
293
+
294
+
295
+ def build_connect_urls(port: int) -> list:
296
+ """手机扫码可用的连接地址,按优先级排序:公网域名 > Tailscale > 局域网。"""
297
+ urls = []
298
+ if PUBLIC_URL:
299
+ label = ("公网 · 任何网络(重启会变,连不上重新扫码)" if PUBLIC_URL_KIND == "quick"
300
+ else "公网 · 任何网络")
301
+ urls.append({"label": label, "url": "%s/?token=%s" % (PUBLIC_URL, token())})
302
+ ts = tailscale_ip()
303
+ if ts:
304
+ urls.append({"label": "Tailscale · 外网随时随地",
305
+ "url": "http://%s:%d/?token=%s" % (ts, port, token())})
306
+ lan = lan_ip()
307
+ if lan:
308
+ urls.append({"label": "局域网 · 同一 WiFi",
309
+ "url": "http://%s:%d/?token=%s" % (lan, port, token())})
310
+ return urls