agent-loft 0.0.0-stage → 0.2.8

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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -2
  3. package/bin/cli.js +52 -0
  4. package/package.json +64 -4
  5. package/src/agents/claude/account.ts +122 -0
  6. package/src/agents/claude/driver.ts +216 -0
  7. package/src/agents/claude/errors.ts +129 -0
  8. package/src/agents/claude/permissions.ts +260 -0
  9. package/src/agents/claude/probe.ts +54 -0
  10. package/src/agents/claude/session.ts +487 -0
  11. package/src/agents/codex/account.ts +122 -0
  12. package/src/agents/codex/app-server.ts +346 -0
  13. package/src/agents/codex/driver.ts +210 -0
  14. package/src/agents/codex/errors.ts +158 -0
  15. package/src/agents/codex/protocol.ts +284 -0
  16. package/src/agents/codex/refusals.ts +62 -0
  17. package/src/agents/codex/session.ts +773 -0
  18. package/src/channels/feishu/bot.ts +669 -0
  19. package/src/channels/feishu/cards.ts +360 -0
  20. package/src/channels/feishu/client.ts +185 -0
  21. package/src/channels/feishu/envelope.ts +117 -0
  22. package/src/channels/feishu/index.ts +62 -0
  23. package/src/channels/feishu/message.ts +115 -0
  24. package/src/channels/feishu/register.ts +171 -0
  25. package/src/channels/feishu/slash-sync.ts +192 -0
  26. package/src/channels/feishu/turn-stream.ts +98 -0
  27. package/src/channels/web/api.ts +645 -0
  28. package/src/channels/web/auth.ts +204 -0
  29. package/src/channels/web/http.ts +118 -0
  30. package/src/channels/web/push.ts +218 -0
  31. package/src/channels/web/server.ts +446 -0
  32. package/src/cli/doctor.ts +39 -0
  33. package/src/cli/import.ts +155 -0
  34. package/src/cli/pair.ts +35 -0
  35. package/src/cli/passwd.ts +15 -0
  36. package/src/cli/service.ts +137 -0
  37. package/src/core/agent-env.ts +11 -0
  38. package/src/core/command-catalog.ts +112 -0
  39. package/src/core/commands.ts +104 -0
  40. package/src/core/config.ts +285 -0
  41. package/src/core/dedup.ts +16 -0
  42. package/src/core/footers.ts +88 -0
  43. package/src/core/hub.ts +1152 -0
  44. package/src/core/interactions.ts +273 -0
  45. package/src/core/pairing.ts +156 -0
  46. package/src/core/store.ts +178 -0
  47. package/src/core/types.ts +213 -0
  48. package/src/core/uploads.ts +200 -0
  49. package/src/home.ts +43 -0
  50. package/src/main.ts +68 -0
  51. package/web/app.js +3731 -0
  52. package/web/apple-touch-icon.png +0 -0
  53. package/web/icon-maskable.svg +13 -0
  54. package/web/icon.svg +12 -0
  55. package/web/index.html +23 -0
  56. package/web/manifest.webmanifest +15 -0
  57. package/web/style.css +2604 -0
  58. package/web/sw.js +76 -0
@@ -0,0 +1,204 @@
1
+ // Web 登录鉴权。
2
+ //
3
+ // 这个页面能让 agent 在这台机器上执行命令,等同于一个远程 shell,所以:
4
+ // - 密码用 scrypt 哈希存,不存明文;
5
+ // - 首次没有密码时,必须拿终端 / 日志里打印的一次性设置码才能设密码
6
+ // (否则谁先打开页面谁就成了主人);
7
+ // - 登录态是随机 token,服务端只存它的哈希,HttpOnly cookie;
8
+ // - 同一 IP 连续输错会被锁一段时间。
9
+ import crypto from "node:crypto";
10
+ import fs from "node:fs";
11
+ import { EventEmitter } from "node:events";
12
+ import path from "node:path";
13
+ import { config, saveConfig } from "../../core/config.js";
14
+ import { dataDir, readJson, writeJsonAtomic } from "../../home.js";
15
+
16
+ const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
17
+
18
+ /** 登录态被撤销(退出、改密码、踢设备)时通知 WebSocket 那边立刻断开对应连接 */
19
+ export const authEvents = new EventEmitter<{ revoked: [] }>();
20
+ const sessionsPath = path.join(dataDir, "web-sessions.json");
21
+ const setupTokenPath = path.join(dataDir, "web-setup-token");
22
+
23
+ export type WebSession = {
24
+ createdAt: number;
25
+ expiresAt: number;
26
+ lastSeen: number;
27
+ ip: string;
28
+ userAgent: string;
29
+ };
30
+
31
+ let sessions: Record<string, WebSession> | null = null;
32
+ let loadedMtime = 0;
33
+
34
+ function mtimeOf(file: string): number {
35
+ try {
36
+ return fs.statSync(file).mtimeMs;
37
+ } catch {
38
+ return 0;
39
+ }
40
+ }
41
+
42
+ function loadSessions(): Record<string, WebSession> {
43
+ // 终端里 agent-loft passwd 会清空登录态:文件变了就重新读,让被踢的设备立刻失效
44
+ const mtime = mtimeOf(sessionsPath);
45
+ if (!sessions || mtime !== loadedMtime) {
46
+ sessions = readJson<Record<string, WebSession>>(sessionsPath, {});
47
+ loadedMtime = mtime;
48
+ }
49
+ const now = Date.now();
50
+ for (const [id, s] of Object.entries(sessions)) if (s.expiresAt < now) delete sessions[id];
51
+ return sessions;
52
+ }
53
+
54
+ function saveSessions(): void {
55
+ writeJsonAtomic(sessionsPath, sessions ?? {});
56
+ loadedMtime = mtimeOf(sessionsPath);
57
+ }
58
+
59
+ function sha256(value: string): string {
60
+ return crypto.createHash("sha256").update(value).digest("hex");
61
+ }
62
+
63
+ // ---------- 密码 ----------
64
+
65
+ export function hashPassword(password: string): string {
66
+ const salt = crypto.randomBytes(16);
67
+ const key = crypto.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
68
+ return `scrypt$${salt.toString("hex")}$${key.toString("hex")}`;
69
+ }
70
+
71
+ export function verifyPassword(password: string, stored: string): boolean {
72
+ const [scheme, saltHex, keyHex] = stored.split("$");
73
+ if (scheme !== "scrypt" || !saltHex || !keyHex) return false;
74
+ const expected = Buffer.from(keyHex, "hex");
75
+ const actual = crypto.scryptSync(password, Buffer.from(saltHex, "hex"), expected.length, { N: 16384, r: 8, p: 1 });
76
+ return crypto.timingSafeEqual(expected, actual);
77
+ }
78
+
79
+ export function hasPassword(): boolean {
80
+ return !!config().web.passwordHash;
81
+ }
82
+
83
+ export function setPassword(password: string): void {
84
+ if (password.length < 8) throw new Error("密码至少 8 位");
85
+ const cfg = structuredClone(config());
86
+ cfg.web.passwordHash = hashPassword(password);
87
+ saveConfig(cfg);
88
+ // 改密码 = 所有已登录设备下线
89
+ sessions = {};
90
+ saveSessions();
91
+ fs.rmSync(setupTokenPath, { force: true });
92
+ authEvents.emit("revoked");
93
+ }
94
+
95
+ // ---------- 首次设置码 ----------
96
+
97
+ /** 没有密码时生成(或沿用)一次性设置码,打印到日志里。 */
98
+ export function ensureSetupToken(): string | null {
99
+ if (hasPassword()) return null;
100
+ try {
101
+ const existing = fs.readFileSync(setupTokenPath, "utf8").trim();
102
+ if (existing) return existing;
103
+ } catch {
104
+ // 没有就新建
105
+ }
106
+ const token = crypto.randomBytes(6).toString("hex").toUpperCase();
107
+ fs.mkdirSync(dataDir, { recursive: true });
108
+ fs.writeFileSync(setupTokenPath, token, { mode: 0o600 });
109
+ return token;
110
+ }
111
+
112
+ export function checkSetupToken(token: string): boolean {
113
+ const expected = ensureSetupToken();
114
+ if (!expected) return false;
115
+ const a = Buffer.from(token.trim().toUpperCase());
116
+ const b = Buffer.from(expected);
117
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
118
+ }
119
+
120
+ // ---------- 登录态 ----------
121
+
122
+ export function createSession(ip: string, userAgent: string): string {
123
+ const token = crypto.randomBytes(32).toString("base64url");
124
+ const now = Date.now();
125
+ loadSessions()[sha256(token)] = { createdAt: now, expiresAt: now + SESSION_TTL_MS, lastSeen: now, ip, userAgent };
126
+ saveSessions();
127
+ return token;
128
+ }
129
+
130
+ export function validateSession(token: string | undefined): WebSession | undefined {
131
+ if (!token || !hasPassword()) return undefined;
132
+ const s = loadSessions()[sha256(token)];
133
+ if (!s) return undefined;
134
+ const now = Date.now();
135
+ if (now - s.lastSeen > 60_000) {
136
+ s.lastSeen = now;
137
+ saveSessions();
138
+ }
139
+ return s;
140
+ }
141
+
142
+ export function destroySession(token: string | undefined): void {
143
+ if (!token) return;
144
+ delete loadSessions()[sha256(token)];
145
+ saveSessions();
146
+ authEvents.emit("revoked");
147
+ }
148
+
149
+ export function listSessions(): (WebSession & { id: string })[] {
150
+ return Object.entries(loadSessions()).map(([id, s]) => ({ id: id.slice(0, 12), ...s }));
151
+ }
152
+
153
+ export function revokeSession(idPrefix: string): boolean {
154
+ const all = loadSessions();
155
+ const hit = Object.keys(all).find((id) => id.startsWith(idPrefix));
156
+ if (!hit) return false;
157
+ delete all[hit];
158
+ saveSessions();
159
+ authEvents.emit("revoked");
160
+ return true;
161
+ }
162
+
163
+ // ---------- 防爆破 ----------
164
+ //
165
+ // 额度在「真要验密码的那一刻」同步扣掉,而不是先查后记:后者中间隔着读请求体的
166
+ // await,攻击者攒一大批请求同时提交,每个都能通过检查。
167
+
168
+ const MAX_ATTEMPTS = 5;
169
+ const WINDOW_MS = 15 * 60 * 1000;
170
+ /** 全局:就算对方能换 IP,一分钟内总共也只能试这么多次 */
171
+ const GLOBAL_MAX_PER_MIN = 30;
172
+
173
+ const attempts = new Map<string, { count: number; first: number; until: number }>();
174
+ let globalWindow = { start: 0, count: 0 };
175
+
176
+ /**
177
+ * 占用一次尝试额度。返回 0 表示可以验证;否则返回还要等多少毫秒。
178
+ * 同步执行,并发请求之间不会穿插。
179
+ */
180
+ export function takeAttempt(ip: string): number {
181
+ const now = Date.now();
182
+ if (now - globalWindow.start >= 60_000) globalWindow = { start: now, count: 0 };
183
+ if (globalWindow.count >= GLOBAL_MAX_PER_MIN) return globalWindow.start + 60_000 - now;
184
+
185
+ let a = attempts.get(ip);
186
+ if (a && a.until > now) return a.until - now;
187
+ if (!a || now - a.first > WINDOW_MS) {
188
+ if (attempts.size > 10_000) attempts.clear();
189
+ a = { count: 0, first: now, until: 0 };
190
+ attempts.set(ip, a);
191
+ }
192
+ if (a.count >= MAX_ATTEMPTS) {
193
+ a.until = now + WINDOW_MS;
194
+ return WINDOW_MS;
195
+ }
196
+ a.count += 1;
197
+ globalWindow.count += 1;
198
+ return 0;
199
+ }
200
+
201
+ /** 验证成功:这个 IP 的计数清零(全局窗口不退,防止拿一个正确密码洗额度) */
202
+ export function clearAttempts(ip: string): void {
203
+ attempts.delete(ip);
204
+ }
@@ -0,0 +1,118 @@
1
+ // HTTP 小工具:不引框架,几十行就够用。
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+ import { config } from "../../core/config.js";
4
+
5
+ export class HttpError extends Error {
6
+ constructor(
7
+ readonly status: number,
8
+ message: string,
9
+ ) {
10
+ super(message);
11
+ }
12
+ }
13
+
14
+ function normalizeIp(ip: string): string {
15
+ return ip.trim().replace(/^::ffff:/, "");
16
+ }
17
+
18
+ function isTrustedProxy(ip: string | undefined): boolean {
19
+ if (!ip) return false;
20
+ const list = config().web.trustedProxies.map(normalizeIp);
21
+ return list.includes(normalizeIp(ip));
22
+ }
23
+
24
+ /** 这条连接是不是经由可信反代来的。只有这时 X-Forwarded-* 才可信。 */
25
+ export function viaTrustedProxy(req: IncomingMessage): boolean {
26
+ return isTrustedProxy(req.socket.remoteAddress);
27
+ }
28
+
29
+ /**
30
+ * 客户端 IP:从 X-Forwarded-For 的最右边往左剥掉可信代理,第一个不可信的就是客户端。
31
+ * 不取最左边那个 —— 那一项是客户端自己能随便填的。
32
+ */
33
+ export function clientIp(req: IncomingMessage): string {
34
+ const remote = req.socket.remoteAddress ?? "unknown";
35
+ if (!viaTrustedProxy(req)) return normalizeIp(remote);
36
+ const fwd = req.headers["x-forwarded-for"];
37
+ const chain = (Array.isArray(fwd) ? fwd.join(",") : (fwd ?? ""))
38
+ .split(",")
39
+ .map((s) => s.trim())
40
+ .filter(Boolean);
41
+ for (let i = chain.length - 1; i >= 0; i--) {
42
+ if (!isTrustedProxy(chain[i])) return normalizeIp(chain[i]!);
43
+ }
44
+ const real = req.headers["x-real-ip"];
45
+ return typeof real === "string" && real ? normalizeIp(real) : normalizeIp(remote);
46
+ }
47
+
48
+ export function isHttps(req: IncomingMessage): boolean {
49
+ if (viaTrustedProxy(req) && req.headers["x-forwarded-proto"] === "https") return true;
50
+ return (req.socket as { encrypted?: boolean }).encrypted === true;
51
+ }
52
+
53
+ export function cookies(req: IncomingMessage): Record<string, string> {
54
+ const out: Record<string, string> = {};
55
+ for (const part of (req.headers.cookie ?? "").split(";")) {
56
+ const at = part.indexOf("=");
57
+ if (at < 0) continue;
58
+ out[part.slice(0, at).trim()] = decodeURIComponent(part.slice(at + 1).trim());
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /**
64
+ * 跨站请求防护:浏览器发的写请求和 WebSocket 握手一定带 Origin,它的 host 必须和
65
+ * 请求的 host 一致。没有 Origin 的(curl 之类)放行 —— 它们本来就带不了别人的 cookie。
66
+ */
67
+ export function sameOrigin(req: IncomingMessage): boolean {
68
+ const origin = req.headers.origin;
69
+ if (!origin) return true;
70
+ let originHost: string;
71
+ try {
72
+ originHost = new URL(origin).host;
73
+ } catch {
74
+ return false;
75
+ }
76
+ const fwdHost = viaTrustedProxy(req) ? req.headers["x-forwarded-host"] : undefined;
77
+ const hosts = [req.headers.host, ...(Array.isArray(fwdHost) ? fwdHost : fwdHost ? [fwdHost] : [])];
78
+ return hosts.some((h) => h?.split(",")[0]?.trim() === originHost);
79
+ }
80
+
81
+ export async function readBody(req: IncomingMessage, limit: number): Promise<Buffer> {
82
+ const chunks: Buffer[] = [];
83
+ let size = 0;
84
+ for await (const chunk of req) {
85
+ size += (chunk as Buffer).length;
86
+ if (size > limit) throw new HttpError(413, `内容太大(上限 ${Math.round(limit / 1024 / 1024)}MB)`);
87
+ chunks.push(chunk as Buffer);
88
+ }
89
+ return Buffer.concat(chunks);
90
+ }
91
+
92
+ export async function readJsonBody<T = Record<string, unknown>>(req: IncomingMessage): Promise<T> {
93
+ const raw = await readBody(req, 1024 * 1024);
94
+ if (raw.length === 0) return {} as T;
95
+ try {
96
+ return JSON.parse(raw.toString("utf8")) as T;
97
+ } catch {
98
+ throw new HttpError(400, "请求体不是合法的 JSON");
99
+ }
100
+ }
101
+
102
+ export function sendJson(res: ServerResponse, status: number, body: unknown): void {
103
+ const data = JSON.stringify(body);
104
+ res.writeHead(status, {
105
+ "Content-Type": "application/json; charset=utf-8",
106
+ "Cache-Control": "no-store",
107
+ "Content-Length": Buffer.byteLength(data),
108
+ });
109
+ res.end(data);
110
+ }
111
+
112
+ export const SECURITY_HEADERS: Record<string, string> = {
113
+ "X-Content-Type-Options": "nosniff",
114
+ "X-Frame-Options": "DENY",
115
+ "Referrer-Policy": "same-origin",
116
+ "Content-Security-Policy":
117
+ "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
118
+ };
@@ -0,0 +1,218 @@
1
+ // Web Push:回复完成、需要授权时给手机 / 电脑推系统通知。
2
+ //
3
+ // 不依赖第三方库,按标准自己实现:
4
+ // - VAPID(RFC 8292):用一对 P-256 密钥给推送服务签 JWT,证明推送来自我们;
5
+ // - 消息加密(RFC 8291,aes128gcm):推送服务(Apple / Google / Mozilla)看不到内容。
6
+ // iPhone 上只有「添加到主屏幕」的 PWA 才能收到(iOS 16.4+),macOS Safari / Chrome 直接可用。
7
+ // 订阅接口要求页面是 HTTPS(localhost 除外)。
8
+ import crypto from "node:crypto";
9
+ import path from "node:path";
10
+ import { dataDir, readJson, writeJsonAtomic } from "../../home.js";
11
+
12
+ export type PushSubscriptionRecord = {
13
+ endpoint: string;
14
+ keys: { p256dh: string; auth: string };
15
+ /** 设备描述(User-Agent 摘要),设置页里认设备用 */
16
+ label: string;
17
+ createdAt: number;
18
+ lastOkAt?: number;
19
+ };
20
+
21
+ export type PushPayload = {
22
+ title: string;
23
+ body: string;
24
+ /** 同一 tag 的通知会互相替换,不在通知中心堆一串 */
25
+ tag?: string;
26
+ /** 点开通知后打开的地址 */
27
+ url?: string;
28
+ };
29
+
30
+ type Store = {
31
+ vapid?: { publicKey: string; privateJwk: crypto.JsonWebKey };
32
+ subscriptions: PushSubscriptionRecord[];
33
+ };
34
+
35
+ const FILE = () => path.join(dataDir, "push.json");
36
+ /** VAPID 的 sub 声明:Apple 要求是 mailto: 或 https: 地址 */
37
+ const VAPID_SUBJECT = "https://github.com/qijieinnet/agent-loft";
38
+
39
+ let store: Store | null = null;
40
+
41
+ function load(): Store {
42
+ if (!store) {
43
+ store = readJson<Store>(FILE(), { subscriptions: [] });
44
+ if (!Array.isArray(store.subscriptions)) store.subscriptions = [];
45
+ }
46
+ return store;
47
+ }
48
+
49
+ function persist(): void {
50
+ writeJsonAtomic(FILE(), load());
51
+ }
52
+
53
+ /** 测试用:换了 AGENT_LOFT_HOME 之后重新读 */
54
+ export function resetPushStoreForTest(): void {
55
+ store = null;
56
+ }
57
+
58
+ export const b64url = (buf: Buffer | Uint8Array): string => Buffer.from(buf).toString("base64url");
59
+ export const fromB64url = (s: string): Buffer => Buffer.from(s, "base64url");
60
+
61
+ /** 未压缩格式的 P-256 公钥(0x04 || x || y,65 字节) */
62
+ function rawPublicKey(jwk: crypto.JsonWebKey): Buffer {
63
+ return Buffer.concat([Buffer.from([4]), fromB64url(jwk.x!), fromB64url(jwk.y!)]);
64
+ }
65
+
66
+ function vapid(): { publicKey: string; privateKey: crypto.KeyObject } {
67
+ const s = load();
68
+ if (!s.vapid) {
69
+ const { privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "prime256v1" });
70
+ const privateJwk = privateKey.export({ format: "jwk" });
71
+ s.vapid = { publicKey: b64url(rawPublicKey(privateJwk)), privateJwk };
72
+ persist();
73
+ }
74
+ return { publicKey: s.vapid.publicKey, privateKey: crypto.createPrivateKey({ key: s.vapid.privateJwk, format: "jwk" }) };
75
+ }
76
+
77
+ /** 浏览器订阅时要的 applicationServerKey */
78
+ export function vapidPublicKey(): string {
79
+ return vapid().publicKey;
80
+ }
81
+
82
+ export function vapidHeader(endpoint: string): string {
83
+ const { publicKey, privateKey } = vapid();
84
+ const header = b64url(Buffer.from(JSON.stringify({ typ: "JWT", alg: "ES256" })));
85
+ const claims = b64url(
86
+ Buffer.from(
87
+ JSON.stringify({ aud: new URL(endpoint).origin, exp: Math.floor(Date.now() / 1000) + 12 * 3600, sub: VAPID_SUBJECT }),
88
+ ),
89
+ );
90
+ const signature = crypto.sign("sha256", Buffer.from(`${header}.${claims}`), { key: privateKey, dsaEncoding: "ieee-p1363" });
91
+ return `vapid t=${header}.${claims}.${b64url(signature)}, k=${publicKey}`;
92
+ }
93
+
94
+ const hkdf = (ikm: Buffer, salt: Buffer, info: Buffer, len: number): Buffer =>
95
+ Buffer.from(crypto.hkdfSync("sha256", ikm, salt, info, len));
96
+
97
+ /** RFC 8291 加密:单条记录,内容后加 0x02 分隔符,不填充 */
98
+ export function encryptPayload(plaintext: Buffer, p256dh: string, auth: string): Buffer {
99
+ const uaPublic = fromB64url(p256dh);
100
+ const authSecret = fromB64url(auth);
101
+ const ecdh = crypto.createECDH("prime256v1");
102
+ const asPublic = ecdh.generateKeys();
103
+ const shared = ecdh.computeSecret(uaPublic);
104
+ const keyInfo = Buffer.concat([Buffer.from("WebPush: info\0"), uaPublic, asPublic]);
105
+ const ikm = hkdf(shared, authSecret, keyInfo, 32);
106
+ const salt = crypto.randomBytes(16);
107
+ const cek = hkdf(ikm, salt, Buffer.from("Content-Encoding: aes128gcm\0"), 16);
108
+ const nonce = hkdf(ikm, salt, Buffer.from("Content-Encoding: nonce\0"), 12);
109
+ const cipher = crypto.createCipheriv("aes-128-gcm", cek, nonce);
110
+ const body = Buffer.concat([cipher.update(Buffer.concat([plaintext, Buffer.from([2])])), cipher.final(), cipher.getAuthTag()]);
111
+ const rs = Buffer.alloc(4);
112
+ rs.writeUInt32BE(4096);
113
+ return Buffer.concat([salt, rs, Buffer.from([asPublic.length]), asPublic, body]);
114
+ }
115
+
116
+ // ---------- 订阅管理 ----------
117
+
118
+ function validSubscription(sub: unknown): sub is { endpoint: string; keys: { p256dh: string; auth: string } } {
119
+ const s = sub as { endpoint?: unknown; keys?: { p256dh?: unknown; auth?: unknown } };
120
+ if (typeof s?.endpoint !== "string" || typeof s.keys?.p256dh !== "string" || typeof s.keys?.auth !== "string") return false;
121
+ try {
122
+ const url = new URL(s.endpoint);
123
+ // 只往 https 推:不然这个接口能被拿来让服务器去请求内网任意地址
124
+ if (url.protocol !== "https:") return false;
125
+ } catch {
126
+ return false;
127
+ }
128
+ return fromB64url(s.keys.p256dh).length === 65 && fromB64url(s.keys.auth).length >= 16;
129
+ }
130
+
131
+ function describeUa(ua: string): string {
132
+ const device = /iPhone/.test(ua) ? "iPhone" : /iPad/.test(ua) ? "iPad" : /Mac OS X/.test(ua) ? "Mac" : /Android/.test(ua) ? "Android" : /Windows/.test(ua) ? "Windows" : "设备";
133
+ const browser = /Edg\//.test(ua) ? "Edge" : /Chrome\//.test(ua) ? "Chrome" : /Firefox\//.test(ua) ? "Firefox" : /Safari\//.test(ua) ? "Safari" : "";
134
+ return browser ? `${device} · ${browser}` : device;
135
+ }
136
+
137
+ export function addSubscription(sub: unknown, userAgent: string): PushSubscriptionRecord {
138
+ if (!validSubscription(sub)) throw new Error("订阅信息不完整");
139
+ const s = load();
140
+ const record: PushSubscriptionRecord = {
141
+ endpoint: sub.endpoint,
142
+ keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth },
143
+ label: describeUa(userAgent),
144
+ createdAt: Date.now(),
145
+ };
146
+ s.subscriptions = [...s.subscriptions.filter((x) => x.endpoint !== sub.endpoint), record];
147
+ persist();
148
+ return record;
149
+ }
150
+
151
+ export function removeSubscription(endpoint: string): boolean {
152
+ const s = load();
153
+ const before = s.subscriptions.length;
154
+ s.subscriptions = s.subscriptions.filter((x) => x.endpoint !== endpoint);
155
+ if (s.subscriptions.length === before) return false;
156
+ persist();
157
+ return true;
158
+ }
159
+
160
+ /** 给设置页看的:不带密钥 */
161
+ export function listSubscriptions(): { endpoint: string; label: string; createdAt: number; lastOkAt?: number }[] {
162
+ return load().subscriptions.map(({ endpoint, label, createdAt, lastOkAt }) => ({
163
+ endpoint,
164
+ label,
165
+ createdAt,
166
+ ...(lastOkAt ? { lastOkAt } : {}),
167
+ }));
168
+ }
169
+
170
+ export function hasSubscriptions(): boolean {
171
+ return load().subscriptions.length > 0;
172
+ }
173
+
174
+ // ---------- 发送 ----------
175
+
176
+ async function sendOne(sub: PushSubscriptionRecord, payload: PushPayload): Promise<boolean> {
177
+ const body = encryptPayload(Buffer.from(JSON.stringify(payload)), sub.keys.p256dh, sub.keys.auth);
178
+ const res = await fetch(sub.endpoint, {
179
+ method: "POST",
180
+ headers: {
181
+ Authorization: vapidHeader(sub.endpoint),
182
+ "Content-Encoding": "aes128gcm",
183
+ "Content-Type": "application/octet-stream",
184
+ TTL: String(24 * 3600),
185
+ Urgency: "high",
186
+ },
187
+ body,
188
+ signal: AbortSignal.timeout(15_000),
189
+ });
190
+ if (res.status === 404 || res.status === 410) {
191
+ // 订阅失效了(用户关了通知、删了 PWA):清掉,别一直推
192
+ removeSubscription(sub.endpoint);
193
+ console.warn(`[push] 订阅已失效,已移除:${sub.label}`);
194
+ return false;
195
+ }
196
+ if (!res.ok) {
197
+ console.error(`[push] 推送失败(${sub.label}):${res.status} ${await res.text().catch(() => "")}`);
198
+ return false;
199
+ }
200
+ sub.lastOkAt = Date.now();
201
+ return true;
202
+ }
203
+
204
+ /** 推给所有订阅的设备,返回成功的数量。单个设备失败不影响别的 */
205
+ export async function pushToAll(payload: PushPayload): Promise<number> {
206
+ const subs = [...load().subscriptions];
207
+ if (subs.length === 0) return 0;
208
+ const results = await Promise.all(
209
+ subs.map((sub) =>
210
+ sendOne(sub, payload).catch((err) => {
211
+ console.error(`[push] 推送出错(${sub.label}):`, err instanceof Error ? err.message : err);
212
+ return false;
213
+ }),
214
+ ),
215
+ );
216
+ if (results.some(Boolean)) persist();
217
+ return results.filter(Boolean).length;
218
+ }