dsh-plugin-weapp-gateway 0.8.23 → 0.8.34

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.
Files changed (71) hide show
  1. package/LICENSE +21 -21
  2. package/PROTOCOL.md +1545 -1486
  3. package/README.md +503 -464
  4. package/THIRD-PARTY-NOTICES/dsh-cost-meter.txt +31 -31
  5. package/bin/setup-ip.mjs +474 -474
  6. package/cordis.patch.yml +50 -50
  7. package/docs/agent-team-app-integration.md +945 -0
  8. package/docs/blog-mobile-gateway.md +542 -542
  9. package/docs/dsh-0.1.5-rc.2-compatibility-audit.md +255 -255
  10. package/docs/dsh-0.1.6-alpha.1-compatibility-audit.md +122 -122
  11. package/docs/dsh-0.1.7-rc.1-compatibility-audit.md +52 -52
  12. package/docs/dsh-0.2.0-rc.2-plugin-management-mobile-integration.md +109 -109
  13. package/docs/dsh-rc2-mobile-integration.md +155 -155
  14. package/docs/file-upload-app-integration.md +250 -0
  15. package/docs/multi-gateway-app-integration.md +141 -125
  16. package/docs/multi-gateway-phase1-acceptance.md +179 -179
  17. package/docs/multi-gateway-todo.md +137 -137
  18. package/docs/remote-gateway-refactor-plan.md +52 -52
  19. package/docs/runtime-architecture.architecture.json +264 -264
  20. package/docs/runtime-architecture.html +15001 -15001
  21. package/docs/runtime-architecture.visual-check.html +32 -32
  22. package/docs/runtime-architecture.visual-check.json +548 -548
  23. package/docs/session-agent-preset-app-integration.md +93 -93
  24. package/docs/turn-outline-app-integration.md +196 -0
  25. package/docs/typert-remote-gateway-feature-checklist.md +294 -294
  26. package/docs/voice-input-implementation.md +219 -219
  27. package/docs/workspace-changes-mobile-integration.md +32 -32
  28. package/helper/dsh_mobile_gateway_helper.py +227 -227
  29. package/lib/account-bridge.mjs +57 -57
  30. package/lib/client.js +1409 -1361
  31. package/lib/cloudflare-tunnel.mjs +314 -314
  32. package/lib/cloudflared-binary.mjs +135 -135
  33. package/lib/context-bridge.mjs +3338 -1715
  34. package/lib/cost-meter-bridge.mjs +794 -794
  35. package/lib/cost-meter-dashboard-bridge.mjs +1655 -1449
  36. package/lib/devices.js +273 -245
  37. package/lib/dsh-host-adapter.mjs +508 -426
  38. package/lib/file-upload.mjs +205 -0
  39. package/lib/gateway-probe.mjs +6 -6
  40. package/lib/gateway-state.mjs +57 -57
  41. package/lib/gateway-tools.mjs +36 -36
  42. package/lib/history-cache.mjs +66 -0
  43. package/lib/index.mjs +4390 -3803
  44. package/lib/plugin-management.mjs +251 -233
  45. package/lib/public-endpoint-check.mjs +61 -61
  46. package/lib/relay-connector.mjs +213 -181
  47. package/lib/relay-state.mjs +83 -76
  48. package/lib/session-follower.mjs +140 -136
  49. package/lib/session-resume-bridge.mjs +86 -0
  50. package/lib/session-target.mjs +29 -0
  51. package/lib/speech-input.mjs +192 -192
  52. package/lib/vendor/cost-meter/aliyun-balance.mjs +123 -123
  53. package/lib/vendor/cost-meter/coding-plans.mjs +1668 -1668
  54. package/lib/vendor/cost-meter/credentials-shim.mjs +8 -8
  55. package/lib/vendor/cost-meter/custom-balance.mjs +281 -281
  56. package/lib/vendor/cost-meter/factory-quota.mjs +69 -69
  57. package/lib/vendor/cost-meter/gateway-quota-adapters.mjs +1006 -1006
  58. package/lib/vendor/cost-meter/gateway-quotas.mjs +664 -664
  59. package/lib/vendor/cost-meter/home-paths-shim.mjs +13 -13
  60. package/lib/vendor/cost-meter/net.mjs +183 -183
  61. package/lib/vendor/cost-meter/opendesign.mjs +204 -204
  62. package/lib/vendor/cost-meter/tokenrhythm-balance.mjs +95 -95
  63. package/lib/vendor/cost-meter/workbuddy-accounts.mjs +44 -44
  64. package/lib/vendor/cost-meter/workbuddy-transport.mjs +38 -38
  65. package/lib/vendor/cost-meter/workbuddy.mjs +356 -356
  66. package/lib/web-restart.mjs +47 -47
  67. package/lib/wire-json.mjs +7 -7
  68. package/lib/workspace-changes-bridge.mjs +32 -32
  69. package/package.json +54 -54
  70. package/relay/agent-connector.js +588 -569
  71. package/relay/vendor/tweetnacl.js +2391 -2391
@@ -1,227 +1,227 @@
1
- #!/usr/bin/env python3
2
- # Managed by dsh-plugin-weapp-gateway
3
- """Root-only, deliberately narrow system helper for the mobile gateway."""
4
-
5
- import argparse
6
- import ipaddress
7
- import json
8
- import os
9
- import pathlib
10
- import re
11
- import shutil
12
- import socketserver
13
- import subprocess
14
- import sys
15
-
16
-
17
- VERSION = 1
18
- MARKER = "# Managed by dsh-plugin-weapp-gateway"
19
- SOCKET_PATH = "/run/dsh-mobile-gateway/helper.sock"
20
- CONFIG_DIR = pathlib.Path("/etc/dsh-mobile-gateway")
21
- PUBLIC_URL_FILE = CONFIG_DIR / "public-url"
22
- NGINX_CONFIG = pathlib.Path("/etc/nginx/conf.d/dsh-mobile-gateway.conf")
23
- WEBROOT = pathlib.Path("/var/lib/dsh-mobile-gateway/acme")
24
- CERTBOT_HOME = pathlib.Path("/opt/dsh-mobile-gateway/certbot")
25
- CERTBOT = CERTBOT_HOME / "bin/certbot"
26
-
27
-
28
- def run(args):
29
- subprocess.run(args, check=True)
30
-
31
-
32
- def atomic_managed_write(target, content, mode=0o644):
33
- target = pathlib.Path(target)
34
- if target.exists() and not target.read_text(encoding="utf-8").startswith(MARKER):
35
- raise RuntimeError(f"refusing to overwrite unmanaged file: {target}")
36
- target.parent.mkdir(parents=True, exist_ok=True)
37
- temporary = target.with_name(f"{target.name}.tmp-{os.getpid()}")
38
- temporary.write_text(content, encoding="utf-8")
39
- os.chmod(temporary, mode)
40
- os.replace(temporary, target)
41
-
42
-
43
- def validate_public_ip(value):
44
- try:
45
- address = ipaddress.ip_address(value)
46
- except ValueError as error:
47
- raise ValueError("publicIp must be a valid IPv4 address") from error
48
- if address.version != 4 or not address.is_global:
49
- raise ValueError("publicIp must be a public IPv4 address")
50
- return str(address)
51
-
52
-
53
- def validate_port(value):
54
- if isinstance(value, bool) or not isinstance(value, int) or value < 1 or value > 65535:
55
- raise ValueError("backendPort must be an integer between 1 and 65535")
56
- return value
57
-
58
-
59
- def certificate_name(public_ip):
60
- return f"dsh-mobile-gateway-{public_ip.replace('.', '-')}"
61
-
62
-
63
- def nginx_http_config(public_ip):
64
- return f"""{MARKER}
65
- server {{
66
- listen 80;
67
- server_name {public_ip};
68
-
69
- location ^~ /.well-known/acme-challenge/ {{
70
- root {WEBROOT};
71
- default_type text/plain;
72
- }}
73
-
74
- location / {{ return 404; }}
75
- }}
76
- """
77
-
78
-
79
- def nginx_tls_config(public_ip, backend_port):
80
- live = f"/etc/letsencrypt/live/{certificate_name(public_ip)}"
81
- return nginx_http_config(public_ip) + f"""
82
- server {{
83
- listen 443 ssl;
84
- server_name {public_ip};
85
- server_tokens off;
86
- access_log off;
87
-
88
- ssl_certificate {live}/fullchain.pem;
89
- ssl_certificate_key {live}/privkey.pem;
90
- ssl_protocols TLSv1.2 TLSv1.3;
91
-
92
- location = /ws/mobile {{
93
- if ($args != \"\") {{ return 404; }}
94
- proxy_pass http://127.0.0.1:{backend_port};
95
- proxy_http_version 1.1;
96
- proxy_set_header Host 127.0.0.1:{backend_port};
97
- proxy_set_header Upgrade $http_upgrade;
98
- proxy_set_header Connection \"upgrade\";
99
- proxy_set_header X-Forwarded-Proto https;
100
- proxy_read_timeout 3600s;
101
- proxy_send_timeout 3600s;
102
- proxy_buffering off;
103
- }}
104
-
105
- location / {{ return 404; }}
106
- }}
107
- """
108
-
109
-
110
- def current_status():
111
- public_url = None
112
- if PUBLIC_URL_FILE.exists():
113
- public_url = next((line.strip() for line in PUBLIC_URL_FILE.read_text(encoding="utf-8").splitlines()
114
- if line.strip().startswith("wss://")), None)
115
- backend_port = None
116
- if NGINX_CONFIG.exists():
117
- match = re.search(r"proxy_pass http://127\.0\.0\.1:(\d+);", NGINX_CONFIG.read_text(encoding="utf-8"))
118
- if match:
119
- backend_port = int(match.group(1))
120
- return {
121
- "installed": True,
122
- "version": VERSION,
123
- "configured": bool(public_url and backend_port),
124
- "publicUrl": public_url,
125
- "backendPort": backend_port,
126
- }
127
-
128
-
129
- def ensure_dependencies():
130
- if not shutil.which("nginx") or not CERTBOT.exists():
131
- raise RuntimeError("helper dependencies are missing; rerun init to repair the installation")
132
-
133
-
134
- def configure(public_ip, backend_port):
135
- public_ip = validate_public_ip(public_ip)
136
- backend_port = validate_port(backend_port)
137
- previous = NGINX_CONFIG.read_bytes() if NGINX_CONFIG.exists() else None
138
- if previous is not None and not previous.decode("utf-8").startswith(MARKER):
139
- raise RuntimeError(f"refusing to overwrite unmanaged file: {NGINX_CONFIG}")
140
-
141
- ensure_dependencies()
142
- (WEBROOT / ".well-known/acme-challenge").mkdir(parents=True, exist_ok=True)
143
- try:
144
- atomic_managed_write(NGINX_CONFIG, nginx_http_config(public_ip))
145
- run(["nginx", "-t"])
146
- run(["systemctl", "enable", "--now", "nginx"])
147
- run(["systemctl", "reload", "nginx"])
148
-
149
- args = [
150
- str(CERTBOT), "certonly", "--non-interactive", "--agree-tos",
151
- "--preferred-profile", "shortlived", "--webroot", "--webroot-path", str(WEBROOT),
152
- "--ip-address", public_ip, "--cert-name", certificate_name(public_ip),
153
- "--keep-until-expiring", "--register-unsafely-without-email",
154
- ]
155
- run(args)
156
- atomic_managed_write(NGINX_CONFIG, nginx_tls_config(public_ip, backend_port))
157
- atomic_managed_write(PUBLIC_URL_FILE, f"{MARKER}\nwss://{public_ip}/ws/mobile\n")
158
- run(["nginx", "-t"])
159
- run(["systemctl", "reload", "nginx"])
160
- except Exception:
161
- if previous is None:
162
- NGINX_CONFIG.unlink(missing_ok=True)
163
- else:
164
- NGINX_CONFIG.write_bytes(previous)
165
- if shutil.which("nginx"):
166
- subprocess.run(["nginx", "-t"], check=False)
167
- subprocess.run(["systemctl", "reload", "nginx"], check=False)
168
- raise
169
- return current_status()
170
-
171
-
172
- def dispatch(message):
173
- action = message.get("action")
174
- if action == "status":
175
- return current_status()
176
- if action == "configure":
177
- return configure(message.get("publicIp"), message.get("backendPort"))
178
- raise ValueError("unsupported helper action")
179
-
180
-
181
- class Handler(socketserver.StreamRequestHandler):
182
- def handle(self):
183
- try:
184
- raw = self.rfile.readline(65537)
185
- if not raw or len(raw) > 65536:
186
- raise ValueError("invalid helper request")
187
- message = json.loads(raw.decode("utf-8"))
188
- if not isinstance(message, dict):
189
- raise ValueError("helper request must be an object")
190
- response = {"ok": True, "result": dispatch(message)}
191
- except Exception as error: # Return a typed error; never expose a traceback.
192
- response = {"ok": False, "error": str(error)}
193
- self.wfile.write((json.dumps(response, separators=(",", ":")) + "\n").encode("utf-8"))
194
-
195
-
196
- class Server(socketserver.UnixStreamServer):
197
- allow_reuse_address = True
198
-
199
-
200
- def serve(uid, socket_path):
201
- if os.geteuid() != 0:
202
- raise RuntimeError("helper must run as root")
203
- path = pathlib.Path(socket_path)
204
- path.parent.mkdir(parents=True, exist_ok=True)
205
- path.unlink(missing_ok=True)
206
- with Server(str(path), Handler) as server:
207
- os.chown(path, uid, -1)
208
- os.chmod(path, 0o600)
209
- server.serve_forever()
210
-
211
-
212
- def main():
213
- parser = argparse.ArgumentParser()
214
- parser.add_argument("--uid", type=int, required=True)
215
- parser.add_argument("--socket", default=SOCKET_PATH)
216
- options = parser.parse_args()
217
- if options.uid < 1:
218
- raise ValueError("uid must identify a non-root user")
219
- serve(options.uid, options.socket)
220
-
221
-
222
- if __name__ == "__main__":
223
- try:
224
- main()
225
- except Exception as error:
226
- print(f"dsh-mobile-gateway-helper: {error}", file=sys.stderr)
227
- raise SystemExit(1)
1
+ #!/usr/bin/env python3
2
+ # Managed by dsh-plugin-weapp-gateway
3
+ """Root-only, deliberately narrow system helper for the mobile gateway."""
4
+
5
+ import argparse
6
+ import ipaddress
7
+ import json
8
+ import os
9
+ import pathlib
10
+ import re
11
+ import shutil
12
+ import socketserver
13
+ import subprocess
14
+ import sys
15
+
16
+
17
+ VERSION = 1
18
+ MARKER = "# Managed by dsh-plugin-weapp-gateway"
19
+ SOCKET_PATH = "/run/dsh-mobile-gateway/helper.sock"
20
+ CONFIG_DIR = pathlib.Path("/etc/dsh-mobile-gateway")
21
+ PUBLIC_URL_FILE = CONFIG_DIR / "public-url"
22
+ NGINX_CONFIG = pathlib.Path("/etc/nginx/conf.d/dsh-mobile-gateway.conf")
23
+ WEBROOT = pathlib.Path("/var/lib/dsh-mobile-gateway/acme")
24
+ CERTBOT_HOME = pathlib.Path("/opt/dsh-mobile-gateway/certbot")
25
+ CERTBOT = CERTBOT_HOME / "bin/certbot"
26
+
27
+
28
+ def run(args):
29
+ subprocess.run(args, check=True)
30
+
31
+
32
+ def atomic_managed_write(target, content, mode=0o644):
33
+ target = pathlib.Path(target)
34
+ if target.exists() and not target.read_text(encoding="utf-8").startswith(MARKER):
35
+ raise RuntimeError(f"refusing to overwrite unmanaged file: {target}")
36
+ target.parent.mkdir(parents=True, exist_ok=True)
37
+ temporary = target.with_name(f"{target.name}.tmp-{os.getpid()}")
38
+ temporary.write_text(content, encoding="utf-8")
39
+ os.chmod(temporary, mode)
40
+ os.replace(temporary, target)
41
+
42
+
43
+ def validate_public_ip(value):
44
+ try:
45
+ address = ipaddress.ip_address(value)
46
+ except ValueError as error:
47
+ raise ValueError("publicIp must be a valid IPv4 address") from error
48
+ if address.version != 4 or not address.is_global:
49
+ raise ValueError("publicIp must be a public IPv4 address")
50
+ return str(address)
51
+
52
+
53
+ def validate_port(value):
54
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1 or value > 65535:
55
+ raise ValueError("backendPort must be an integer between 1 and 65535")
56
+ return value
57
+
58
+
59
+ def certificate_name(public_ip):
60
+ return f"dsh-mobile-gateway-{public_ip.replace('.', '-')}"
61
+
62
+
63
+ def nginx_http_config(public_ip):
64
+ return f"""{MARKER}
65
+ server {{
66
+ listen 80;
67
+ server_name {public_ip};
68
+
69
+ location ^~ /.well-known/acme-challenge/ {{
70
+ root {WEBROOT};
71
+ default_type text/plain;
72
+ }}
73
+
74
+ location / {{ return 404; }}
75
+ }}
76
+ """
77
+
78
+
79
+ def nginx_tls_config(public_ip, backend_port):
80
+ live = f"/etc/letsencrypt/live/{certificate_name(public_ip)}"
81
+ return nginx_http_config(public_ip) + f"""
82
+ server {{
83
+ listen 443 ssl;
84
+ server_name {public_ip};
85
+ server_tokens off;
86
+ access_log off;
87
+
88
+ ssl_certificate {live}/fullchain.pem;
89
+ ssl_certificate_key {live}/privkey.pem;
90
+ ssl_protocols TLSv1.2 TLSv1.3;
91
+
92
+ location = /ws/mobile {{
93
+ if ($args != \"\") {{ return 404; }}
94
+ proxy_pass http://127.0.0.1:{backend_port};
95
+ proxy_http_version 1.1;
96
+ proxy_set_header Host 127.0.0.1:{backend_port};
97
+ proxy_set_header Upgrade $http_upgrade;
98
+ proxy_set_header Connection \"upgrade\";
99
+ proxy_set_header X-Forwarded-Proto https;
100
+ proxy_read_timeout 3600s;
101
+ proxy_send_timeout 3600s;
102
+ proxy_buffering off;
103
+ }}
104
+
105
+ location / {{ return 404; }}
106
+ }}
107
+ """
108
+
109
+
110
+ def current_status():
111
+ public_url = None
112
+ if PUBLIC_URL_FILE.exists():
113
+ public_url = next((line.strip() for line in PUBLIC_URL_FILE.read_text(encoding="utf-8").splitlines()
114
+ if line.strip().startswith("wss://")), None)
115
+ backend_port = None
116
+ if NGINX_CONFIG.exists():
117
+ match = re.search(r"proxy_pass http://127\.0\.0\.1:(\d+);", NGINX_CONFIG.read_text(encoding="utf-8"))
118
+ if match:
119
+ backend_port = int(match.group(1))
120
+ return {
121
+ "installed": True,
122
+ "version": VERSION,
123
+ "configured": bool(public_url and backend_port),
124
+ "publicUrl": public_url,
125
+ "backendPort": backend_port,
126
+ }
127
+
128
+
129
+ def ensure_dependencies():
130
+ if not shutil.which("nginx") or not CERTBOT.exists():
131
+ raise RuntimeError("helper dependencies are missing; rerun init to repair the installation")
132
+
133
+
134
+ def configure(public_ip, backend_port):
135
+ public_ip = validate_public_ip(public_ip)
136
+ backend_port = validate_port(backend_port)
137
+ previous = NGINX_CONFIG.read_bytes() if NGINX_CONFIG.exists() else None
138
+ if previous is not None and not previous.decode("utf-8").startswith(MARKER):
139
+ raise RuntimeError(f"refusing to overwrite unmanaged file: {NGINX_CONFIG}")
140
+
141
+ ensure_dependencies()
142
+ (WEBROOT / ".well-known/acme-challenge").mkdir(parents=True, exist_ok=True)
143
+ try:
144
+ atomic_managed_write(NGINX_CONFIG, nginx_http_config(public_ip))
145
+ run(["nginx", "-t"])
146
+ run(["systemctl", "enable", "--now", "nginx"])
147
+ run(["systemctl", "reload", "nginx"])
148
+
149
+ args = [
150
+ str(CERTBOT), "certonly", "--non-interactive", "--agree-tos",
151
+ "--preferred-profile", "shortlived", "--webroot", "--webroot-path", str(WEBROOT),
152
+ "--ip-address", public_ip, "--cert-name", certificate_name(public_ip),
153
+ "--keep-until-expiring", "--register-unsafely-without-email",
154
+ ]
155
+ run(args)
156
+ atomic_managed_write(NGINX_CONFIG, nginx_tls_config(public_ip, backend_port))
157
+ atomic_managed_write(PUBLIC_URL_FILE, f"{MARKER}\nwss://{public_ip}/ws/mobile\n")
158
+ run(["nginx", "-t"])
159
+ run(["systemctl", "reload", "nginx"])
160
+ except Exception:
161
+ if previous is None:
162
+ NGINX_CONFIG.unlink(missing_ok=True)
163
+ else:
164
+ NGINX_CONFIG.write_bytes(previous)
165
+ if shutil.which("nginx"):
166
+ subprocess.run(["nginx", "-t"], check=False)
167
+ subprocess.run(["systemctl", "reload", "nginx"], check=False)
168
+ raise
169
+ return current_status()
170
+
171
+
172
+ def dispatch(message):
173
+ action = message.get("action")
174
+ if action == "status":
175
+ return current_status()
176
+ if action == "configure":
177
+ return configure(message.get("publicIp"), message.get("backendPort"))
178
+ raise ValueError("unsupported helper action")
179
+
180
+
181
+ class Handler(socketserver.StreamRequestHandler):
182
+ def handle(self):
183
+ try:
184
+ raw = self.rfile.readline(65537)
185
+ if not raw or len(raw) > 65536:
186
+ raise ValueError("invalid helper request")
187
+ message = json.loads(raw.decode("utf-8"))
188
+ if not isinstance(message, dict):
189
+ raise ValueError("helper request must be an object")
190
+ response = {"ok": True, "result": dispatch(message)}
191
+ except Exception as error: # Return a typed error; never expose a traceback.
192
+ response = {"ok": False, "error": str(error)}
193
+ self.wfile.write((json.dumps(response, separators=(",", ":")) + "\n").encode("utf-8"))
194
+
195
+
196
+ class Server(socketserver.UnixStreamServer):
197
+ allow_reuse_address = True
198
+
199
+
200
+ def serve(uid, socket_path):
201
+ if os.geteuid() != 0:
202
+ raise RuntimeError("helper must run as root")
203
+ path = pathlib.Path(socket_path)
204
+ path.parent.mkdir(parents=True, exist_ok=True)
205
+ path.unlink(missing_ok=True)
206
+ with Server(str(path), Handler) as server:
207
+ os.chown(path, uid, -1)
208
+ os.chmod(path, 0o600)
209
+ server.serve_forever()
210
+
211
+
212
+ def main():
213
+ parser = argparse.ArgumentParser()
214
+ parser.add_argument("--uid", type=int, required=True)
215
+ parser.add_argument("--socket", default=SOCKET_PATH)
216
+ options = parser.parse_args()
217
+ if options.uid < 1:
218
+ raise ValueError("uid must identify a non-root user")
219
+ serve(options.uid, options.socket)
220
+
221
+
222
+ if __name__ == "__main__":
223
+ try:
224
+ main()
225
+ except Exception as error:
226
+ print(f"dsh-mobile-gateway-helper: {error}", file=sys.stderr)
227
+ raise SystemExit(1)
@@ -1,57 +1,57 @@
1
- /**
2
- * Account bridge: proxy the Host's DeepSeek account projection to the mobile
3
- * client. Injected only when the Host provides `accountController`; older hosts
4
- * keep answering `account-unavailable`, which the client degrades gracefully.
5
- *
6
- * Wire shape (mobile -> host): { type: 'account', requestId, client }
7
- * Wire shape (host -> mobile): { kind: 'account', requestId, state, profile, balance }
8
- */
9
- export const ACCOUNT_REQUEST_TYPES = new Set(['account'])
10
-
11
- const MAX_REQUEST_ID = 128
12
-
13
- function normalizeClient(value) {
14
- if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
15
- const version = typeof value.version === 'string' ? value.version.trim().slice(0, 64) : ''
16
- if (!version) return undefined
17
- const rawLocale = typeof value.locale === 'string' ? value.locale.trim().slice(0, 32) : ''
18
- const offset = Number(value.timezoneOffsetSeconds)
19
- return {
20
- version,
21
- locale: rawLocale || 'en',
22
- timezoneOffsetSeconds: Number.isFinite(offset) ? Math.trunc(offset) : 0,
23
- }
24
- }
25
-
26
- function settle(promise) {
27
- try { return Promise.resolve(promise).catch(() => null) } catch (_) { return Promise.resolve(null) }
28
- }
29
-
30
- export async function handleAccount(api, msg) {
31
- const requestId = typeof msg?.requestId === 'string' ? msg.requestId.slice(0, MAX_REQUEST_ID) : ''
32
- const fail = (code, message) => ({
33
- kind: 'error', requestType: 'account', ...(requestId ? { requestId } : {}), code, message,
34
- })
35
- if (!requestId) return fail('bad-request', 'account requires a requestId')
36
- const account = api?.account
37
- if (!account || typeof account.getState !== 'function') {
38
- return fail('account-unavailable', 'Account service is unavailable on this host')
39
- }
40
- const client = normalizeClient(msg.client)
41
- try {
42
- const state = (await settle(account.getState())) ?? null
43
- let profile = null
44
- let balance = null
45
- if (client) {
46
- const [profileOutcome, balanceOutcome] = await Promise.all([
47
- typeof account.getProfile === 'function' ? settle(account.getProfile(client)) : null,
48
- typeof account.getBalance === 'function' ? settle(account.getBalance(client)) : null,
49
- ])
50
- profile = profileOutcome ?? null
51
- balance = balanceOutcome ?? null
52
- }
53
- return { kind: 'account', requestId, state, profile, balance }
54
- } catch (_) {
55
- return fail('account-unavailable', 'Account information is unavailable')
56
- }
57
- }
1
+ /**
2
+ * Account bridge: proxy the Host's DeepSeek account projection to the mobile
3
+ * client. Injected only when the Host provides `accountController`; older hosts
4
+ * keep answering `account-unavailable`, which the client degrades gracefully.
5
+ *
6
+ * Wire shape (mobile -> host): { type: 'account', requestId, client }
7
+ * Wire shape (host -> mobile): { kind: 'account', requestId, state, profile, balance }
8
+ */
9
+ export const ACCOUNT_REQUEST_TYPES = new Set(['account'])
10
+
11
+ const MAX_REQUEST_ID = 128
12
+
13
+ function normalizeClient(value) {
14
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
15
+ const version = typeof value.version === 'string' ? value.version.trim().slice(0, 64) : ''
16
+ if (!version) return undefined
17
+ const rawLocale = typeof value.locale === 'string' ? value.locale.trim().slice(0, 32) : ''
18
+ const offset = Number(value.timezoneOffsetSeconds)
19
+ return {
20
+ version,
21
+ locale: rawLocale || 'en',
22
+ timezoneOffsetSeconds: Number.isFinite(offset) ? Math.trunc(offset) : 0,
23
+ }
24
+ }
25
+
26
+ function settle(promise) {
27
+ try { return Promise.resolve(promise).catch(() => null) } catch (_) { return Promise.resolve(null) }
28
+ }
29
+
30
+ export async function handleAccount(api, msg) {
31
+ const requestId = typeof msg?.requestId === 'string' ? msg.requestId.slice(0, MAX_REQUEST_ID) : ''
32
+ const fail = (code, message) => ({
33
+ kind: 'error', requestType: 'account', ...(requestId ? { requestId } : {}), code, message,
34
+ })
35
+ if (!requestId) return fail('bad-request', 'account requires a requestId')
36
+ const account = api?.account
37
+ if (!account || typeof account.getState !== 'function') {
38
+ return fail('account-unavailable', 'Account service is unavailable on this host')
39
+ }
40
+ const client = normalizeClient(msg.client)
41
+ try {
42
+ const state = (await settle(account.getState())) ?? null
43
+ let profile = null
44
+ let balance = null
45
+ if (client) {
46
+ const [profileOutcome, balanceOutcome] = await Promise.all([
47
+ typeof account.getProfile === 'function' ? settle(account.getProfile(client)) : null,
48
+ typeof account.getBalance === 'function' ? settle(account.getBalance(client)) : null,
49
+ ])
50
+ profile = profileOutcome ?? null
51
+ balance = balanceOutcome ?? null
52
+ }
53
+ return { kind: 'account', requestId, state, profile, balance }
54
+ } catch (_) {
55
+ return fail('account-unavailable', 'Account information is unavailable')
56
+ }
57
+ }