dsh-mobile-plugin 0.5.23 → 0.5.26

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 (54) hide show
  1. package/README.md +6 -6
  2. package/knowledge/apps/alipay.md +30 -0
  3. package/knowledge/apps/android-settings.md +30 -0
  4. package/knowledge/apps/bilibili.md +30 -0
  5. package/knowledge/apps/boss-zhipin.md +32 -0
  6. package/knowledge/apps/douyin.md +32 -0
  7. package/knowledge/apps/taobao.md +33 -0
  8. package/knowledge/apps/wechat.md +33 -0
  9. package/knowledge/apps/weibo.md +31 -0
  10. package/knowledge/apps/xiaohongshu.md +29 -0
  11. package/knowledge/apps/zhihu.md +30 -0
  12. package/knowledge/sites/bilibili.com.md +27 -0
  13. package/knowledge/sites/github.com.md +27 -0
  14. package/knowledge/sites/x.com.md +28 -0
  15. package/knowledge/sites/xiaohongshu.com.md +27 -0
  16. package/knowledge/sites/youtube.com.md +28 -0
  17. package/knowledge/sites/zhihu.com.md +27 -0
  18. package/lib/canvas-app.html +73 -72
  19. package/lib/client.js +18 -19
  20. package/lib/impl.js +11 -2
  21. package/lib/mobile/cli/acp.js +3 -2
  22. package/lib/mobile/cli/mobile.js +4 -28
  23. package/lib/mobile/pairing.js +44 -4
  24. package/lib/mobile/phone/confirm.js +189 -0
  25. package/lib/mobile/phone/index.js +21 -1
  26. package/lib/mobile/phone/irreversible.js +146 -0
  27. package/lib/mobile/phone/pacing.js +173 -0
  28. package/lib/mobile/phone/route-hint.js +164 -0
  29. package/lib/mobile/phone/routes-memory.js +133 -0
  30. package/lib/mobile/phone/routes.js +84 -0
  31. package/lib/mobile/phone/service.js +163 -13
  32. package/lib/mobile/phone/tools.js +1190 -62
  33. package/lib/mobile/phone-knowledge.js +301 -0
  34. package/lib/mobile/promo-receiver.cjs +77 -0
  35. package/lib/mobile/schedule.js +64 -7
  36. package/lib/mobile/sessions.js +40 -5
  37. package/lib/mobile/sync-inbox-store.js +181 -0
  38. package/lib/mobile/sync.js +124 -0
  39. package/lib/mobile/transfer-tickets.js +107 -0
  40. package/lib/mobile/wechat.js +26 -1
  41. package/lib/p2p/rendezvous.cjs +106 -0
  42. package/lib/p2p/session.cjs +455 -0
  43. package/lib/p2p/wiring.cjs +93 -0
  44. package/lib/panel/index.html +15 -14
  45. package/lib/relay/connection-check.js +55 -0
  46. package/lib/relay/status.js +4 -2
  47. package/lib/routes/control-plane.js +28 -13
  48. package/lib/routes/mobile-api.js +55 -1
  49. package/lib/shared/e2ee-request.js +27 -7
  50. package/lib/tailcat/host-process.cjs +37 -0
  51. package/lib/tailcat/session.cjs +42 -5
  52. package/lib/tailcat/wiring.cjs +25 -4
  53. package/lib/update-install.cjs +4 -38
  54. package/package.json +4 -2
@@ -0,0 +1,301 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 用法知识库:把 `knowledge/apps/*.md` 与 `knowledge/sites/*.md` 读进来。
5
+ * App 那条按前台包名匹配一段说明,网站那条按当前网页地址(主机名)匹配。
6
+ *
7
+ * 知识只讲事实(这个 App / 网站是什么、界面怎么组织、有哪些功能、有哪些容易混淆的入口),
8
+ * 不写行为规则 —— 该不该做、要不要确认是审批层的事,写进知识里就会和审批层打架。
9
+ *
10
+ * 两条设计取舍:
11
+ * - **按 mtime 缓存**:目录里十几份小文件,每次读屏都重读一遍纯属浪费;mtime 没变就用上一份。
12
+ * - **加载失败安静退化**:知识库目录不存在、某个文件读坏了,都只当这一条没有,绝不抛异常。
13
+ * 知识是锦上添花,不能因为它把整个插件装载掀翻。
14
+ */
15
+
16
+ const fs = require('node:fs');
17
+ const path = require('node:path');
18
+
19
+ /** 知识库根目录:本文件在 lib/mobile/ 下,往上是插件根。 */
20
+ const KNOWLEDGE_ROOT = path.join(__dirname, '..', '..', 'knowledge');
21
+
22
+ /** 默认 App / 网站知识目录。 */
23
+ const DEFAULT_DIR = path.join(KNOWLEDGE_ROOT, 'apps');
24
+ const DEFAULT_SITES_DIR = path.join(KNOWLEDGE_ROOT, 'sites');
25
+
26
+ /** filePath -> { mtimeMs, doc }。文件没改就不重读。 */
27
+ const cache = new Map();
28
+
29
+ /** sessionId -> Set<知识条目名>。同一个对话、同一个 App 只附一次。 */
30
+ const noted = new Map();
31
+
32
+ /**
33
+ * App 知识目录。允许用环境变量覆盖,方便测试「目录不存在时返回空」这条退化路径。
34
+ */
35
+ function knowledgeDir() {
36
+ const override = process.env.DSH_PHONE_KNOWLEDGE_DIR;
37
+ if (typeof override === 'string' && override.trim()) return override.trim();
38
+ return DEFAULT_DIR;
39
+ }
40
+
41
+ /**
42
+ * 网站知识目录。优先用 `DSH_PHONE_SITES_DIR` 覆盖;没有时按 App 目录的同级 `sites`
43
+ * 推(App 目录被 `DSH_PHONE_KNOWLEDGE_DIR` 指到别处时,网站跟着它的兄弟目录走),
44
+ * 否则用插件自带的 `knowledge/sites`。
45
+ */
46
+ function sitesDir() {
47
+ const explicit = process.env.DSH_PHONE_SITES_DIR;
48
+ if (typeof explicit === 'string' && explicit.trim()) return explicit.trim();
49
+ const appsOverride = process.env.DSH_PHONE_KNOWLEDGE_DIR;
50
+ if (typeof appsOverride === 'string' && appsOverride.trim()) {
51
+ return path.join(path.dirname(appsOverride.trim()), 'sites');
52
+ }
53
+ return DEFAULT_SITES_DIR;
54
+ }
55
+
56
+ /** 把一段 `["a","b"]` 或普通标量解析成数组。解析不了就当空数组。 */
57
+ function toArray(value) {
58
+ if (Array.isArray(value)) return value.map((item) => String(item));
59
+ if (typeof value === 'string' && value.trim()) return [value.trim()];
60
+ return [];
61
+ }
62
+
63
+ /** 去掉两端成对的引号。 */
64
+ function unquote(text) {
65
+ if (text.length >= 2) {
66
+ const first = text[0];
67
+ const last = text[text.length - 1];
68
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) return text.slice(1, -1);
69
+ }
70
+ return text;
71
+ }
72
+
73
+ /**
74
+ * 解析一份知识文件:frontmatter 进字段,剩下的是正文。
75
+ *
76
+ * 只认简单的一层 `key: value`,够用了;数组用 JSON(`["com.tencent.mm"]`),
77
+ * 与 Mika 的 APP.md 写法一致。解析不出来的键就留默认值,不抛。
78
+ */
79
+ function parseDoc(filePath, text) {
80
+ const meta = {};
81
+ let body = typeof text === 'string' ? text : '';
82
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(body);
83
+ if (match) {
84
+ const raw = match[1];
85
+ body = match[2] || '';
86
+ for (const line of raw.split(/\r?\n/)) {
87
+ const trimmed = line.trim();
88
+ if (!trimmed || trimmed.startsWith('#')) continue;
89
+ const idx = trimmed.indexOf(':');
90
+ if (idx < 0) continue;
91
+ const key = trimmed.slice(0, idx).trim();
92
+ let value = trimmed.slice(idx + 1).trim();
93
+ if (value.startsWith('[')) {
94
+ try {
95
+ value = JSON.parse(value);
96
+ } catch {
97
+ value = [];
98
+ }
99
+ } else {
100
+ value = unquote(value);
101
+ }
102
+ if (key) meta[key] = value;
103
+ }
104
+ }
105
+ return {
106
+ name: typeof meta.name === 'string' ? meta.name : '',
107
+ display: typeof meta.display === 'string' ? meta.display : '',
108
+ description: typeof meta.description === 'string' ? meta.description : '',
109
+ packages: toArray(meta.packages),
110
+ sites: toArray(meta.sites),
111
+ aliases: toArray(meta.aliases),
112
+ open: typeof meta.open === 'string' ? meta.open : '',
113
+ pace: typeof meta.pace === 'string' ? meta.pace : '',
114
+ body: body.trim(),
115
+ file: path.basename(filePath),
116
+ };
117
+ }
118
+
119
+ /**
120
+ * 读出某个目录下的全部知识条目(带正文,供匹配和守卫使用)。目录不存在或读不了就返回空数组。
121
+ *
122
+ * `dir` 缺省是 App 目录;网站知识传 `sitesDir()`。缓存按完整文件路径做键,两个目录互不干扰,
123
+ * 清缓存时也只清这个目录下的,不会把另一个目录的缓存顺手删掉。
124
+ */
125
+ function loadDocs(dir = knowledgeDir()) {
126
+ let entries;
127
+ try {
128
+ entries = fs.readdirSync(dir);
129
+ } catch {
130
+ // 目录不存在:只清这个目录前缀下的缓存,别误删别的目录的。
131
+ for (const key of [...cache.keys()]) if (key.startsWith(dir + path.sep)) cache.delete(key);
132
+ return [];
133
+ }
134
+ const seen = new Set();
135
+ const docs = [];
136
+ for (const name of entries) {
137
+ if (!name.endsWith('.md')) continue;
138
+ const filePath = path.join(dir, name);
139
+ let stat;
140
+ try {
141
+ stat = fs.statSync(filePath);
142
+ } catch {
143
+ continue;
144
+ }
145
+ if (!stat.isFile()) continue;
146
+ seen.add(filePath);
147
+ const hit = cache.get(filePath);
148
+ if (hit && hit.mtimeMs === stat.mtimeMs) {
149
+ docs.push(hit.doc);
150
+ continue;
151
+ }
152
+ try {
153
+ const text = fs.readFileSync(filePath, 'utf8');
154
+ const doc = parseDoc(filePath, text);
155
+ cache.set(filePath, { mtimeMs: stat.mtimeMs, doc });
156
+ docs.push(doc);
157
+ } catch {
158
+ // 单个文件读坏了只算它没有,其余照常。
159
+ }
160
+ }
161
+ for (const key of [...cache.keys()]) {
162
+ if (key.startsWith(dir + path.sep) && !seen.has(key)) cache.delete(key);
163
+ }
164
+ docs.sort((a, b) => (a.name || a.file).localeCompare(b.name || b.file));
165
+ return docs;
166
+ }
167
+
168
+ /** 列出知识库里的所有 App(不带正文)。 */
169
+ function listApps() {
170
+ return loadDocs().map((doc) => ({
171
+ name: doc.name,
172
+ display: doc.display,
173
+ packages: doc.packages.slice(),
174
+ aliases: doc.aliases.slice(),
175
+ open: doc.open,
176
+ }));
177
+ }
178
+
179
+ /**
180
+ * 按包名或别名找到一条知识。包名精确匹配优先(忽略大小写),其次才看别名。
181
+ * 找不到返回 null。
182
+ */
183
+ function matchApp(packageName) {
184
+ const pkg = typeof packageName === 'string' ? packageName.trim() : '';
185
+ if (!pkg) return null;
186
+ const needle = pkg.toLowerCase();
187
+ const docs = loadDocs();
188
+ const byPackage = docs.find((doc) => doc.packages.some((item) => item.toLowerCase() === needle));
189
+ if (byPackage) return byPackage;
190
+ const byAlias = docs.find((doc) => doc.aliases.some((item) => item.toLowerCase() === needle));
191
+ return byAlias || null;
192
+ }
193
+
194
+ /** 按包名或别名匹配,返回它的正文;匹配不到返回空串。 */
195
+ function matchByPackage(packageName) {
196
+ const doc = matchApp(packageName);
197
+ return doc && doc.body ? doc.body : '';
198
+ }
199
+
200
+ /** 列出知识库里的所有网站(不带正文)。 */
201
+ function listSites() {
202
+ return loadDocs(sitesDir()).map((doc) => ({
203
+ name: doc.name,
204
+ display: doc.display,
205
+ description: doc.description,
206
+ sites: doc.sites.slice(),
207
+ aliases: doc.aliases.slice(),
208
+ }));
209
+ }
210
+
211
+ /** 从一段地址里抽出主机名(小写)。认不出就退回按 `/`、`:` 切的第一段。 */
212
+ function hostOf(raw) {
213
+ const text = typeof raw === 'string' ? raw.trim() : '';
214
+ if (!text) return '';
215
+ try {
216
+ const url = new URL(text.includes('://') ? text : `https://${text}`);
217
+ return url.hostname.toLowerCase();
218
+ } catch {
219
+ return text.toLowerCase().split('/')[0].split(':')[0];
220
+ }
221
+ }
222
+
223
+ /** 归一化站点条目:去协议、去路径、去开头的 www.。 */
224
+ function normalizeSite(site) {
225
+ return String(site || '')
226
+ .trim()
227
+ .toLowerCase()
228
+ .replace(/^https?:\/\//, '')
229
+ .replace(/\/.*$/, '')
230
+ .replace(/^www\./, '');
231
+ }
232
+
233
+ /**
234
+ * 按当前网页地址找到一条网站知识。主机名匹配:完全相等,或是它的子域(`www.youtube.com`
235
+ * 命中 `youtube.com`)。一条都没有时再退回按别名匹配整段输入。找不到返回 null。
236
+ */
237
+ function matchSite(url) {
238
+ const host = hostOf(url);
239
+ if (!host) return null;
240
+ const docs = loadDocs(sitesDir());
241
+ const hit = docs.find((doc) => doc.sites.some((site) => {
242
+ const s = normalizeSite(site);
243
+ if (!s) return false;
244
+ return host === s || host === `www.${s}` || host.endsWith(`.${s}`);
245
+ }));
246
+ if (hit) return hit;
247
+ const needle = typeof url === 'string' ? url.trim().toLowerCase() : '';
248
+ if (!needle) return null;
249
+ return docs.find((doc) => doc.aliases.some((alias) => alias.toLowerCase() === needle)) || null;
250
+ }
251
+
252
+ /** 按网址匹配,返回它的正文;匹配不到返回空串。 */
253
+ function matchBySite(url) {
254
+ const doc = matchSite(url);
255
+ return doc && doc.body ? doc.body : '';
256
+ }
257
+
258
+ /**
259
+ * 给某个对话附一次某个 App 的正文。同一个 sessionId + 同一个 App 只返回一次,之后返回空串。
260
+ * sessionId 或包名为空、匹配不到、正文为空,都返回空串。
261
+ */
262
+ function noteFor(sessionId, packageName) {
263
+ const sid = typeof sessionId === 'string' ? sessionId.trim() : '';
264
+ if (!sid) return '';
265
+ const doc = matchApp(packageName);
266
+ if (!doc || !doc.body) return '';
267
+ let set = noted.get(sid);
268
+ if (!set) {
269
+ set = new Set();
270
+ noted.set(sid, set);
271
+ }
272
+ const key = doc.name || doc.file;
273
+ if (set.has(key)) return '';
274
+ set.add(key);
275
+ return doc.body;
276
+ }
277
+
278
+ /** 测试用:清掉「已经附过」的账本。 */
279
+ function resetNotes() {
280
+ noted.clear();
281
+ }
282
+
283
+ /** 测试用:丢掉 mtime 缓存,强制下次重读。 */
284
+ function resetCache() {
285
+ cache.clear();
286
+ }
287
+
288
+ module.exports = {
289
+ knowledgeDir,
290
+ sitesDir,
291
+ loadDocs,
292
+ listApps,
293
+ listSites,
294
+ matchApp,
295
+ matchByPackage,
296
+ matchSite,
297
+ matchBySite,
298
+ noteFor,
299
+ resetNotes,
300
+ resetCache,
301
+ };
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 商店素材接收端(本机那个 Mac-to-eagle 桌面程序)的坐标。
5
+ *
6
+ * 为什么由插件来报:用户在手机 App 里不该填任何东西(2026-11-07 明确要求)。而「这台电脑的
7
+ * 局域网地址 + 桌面程序的配对密钥」只有插件这一侧知道 —— 它本来就跑在这台电脑上,局域网候选
8
+ * 地址(`lan-network`)和桌面程序写下的密钥文件都在本机,读一下就能把两样一起交给手机。
9
+ *
10
+ * 桌面程序不在跑 / 没装 / 还没生成密钥,就回 null:App 那边据此什么也不做(原图照旧留相册),
11
+ * 不会因为「这台电脑上没有它」而报错。
12
+ */
13
+
14
+ const fs = require('node:fs');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+
18
+ /** 桌面程序的默认端口(见 Mac-to-eagle `LocalAPIServer.preferredPort`)。 */
19
+ const DESKTOP_PORT = 47291;
20
+
21
+ /** 密钥文件:桌面程序用 owner-only 权限写在应用数据目录里。 */
22
+ function accessKeyFile() {
23
+ // 与 Mac-to-eagle `AppPaths.appSupport` 同一套规则:环境变量优先,其次用户应用数据目录。
24
+ const override = process.env.MACTOEAGLE_DATA_DIR;
25
+ const base = override && override.startsWith('/')
26
+ ? override
27
+ : path.join(os.homedir(), 'Library', 'Application Support', 'MacToEagle');
28
+ return path.join(base, 'mobile-access-key');
29
+ }
30
+
31
+ /** 读配对密钥;读不到就 null(不是错误,只说明这台电脑上没有那个程序)。 */
32
+ function readAccessKey() {
33
+ try {
34
+ const value = fs.readFileSync(accessKeyFile(), 'utf8').trim();
35
+ return value || null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * 这台电脑上、手机能拨到的那个局域网地址。
43
+ *
44
+ * 直接用 `lan-network` 那套候选(它已经滤掉了 VPN / 虚拟网卡、把私网排在前面),取第一条的
45
+ * 主机部分 —— 素材回传只需要一个能连通的地址,不需要像会话通道那样挨个试。
46
+ */
47
+ function pickLanHost(lanCandidates) {
48
+ const first = Array.isArray(lanCandidates) ? lanCandidates.find((item) => item && item.url) : null;
49
+ if (!first) return null;
50
+ try {
51
+ return new URL(first.url).hostname;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * 组装给 App 的那一格。
59
+ *
60
+ * @param {{ lanCandidates?: Array<{url?: string}> }} options 与 `/connection-info` 同一份候选
61
+ * @returns {{ url: string, key: string } | null}
62
+ */
63
+ function promoReceiver({ lanCandidates } = {}) {
64
+ const key = readAccessKey();
65
+ if (!key) return null;
66
+ const host = pickLanHost(lanCandidates);
67
+ if (!host) return null;
68
+ return { url: `http://${host}:${DESKTOP_PORT}`, key };
69
+ }
70
+
71
+ module.exports = {
72
+ DESKTOP_PORT,
73
+ accessKeyFile,
74
+ readAccessKey,
75
+ pickLanHost,
76
+ promoReceiver,
77
+ };
@@ -24,13 +24,14 @@ const SCHEDULE_ID_MAX = 200; // 任务 id / messageId 长度上限
24
24
  const SCHEDULE_TITLE_MAX = 200; // 任务标题
25
25
  const SCHEDULE_PROMPT_MAX = 4000; // prompt(列表里只用于预览)
26
26
  const SCHEDULE_EXPRESSION_MAX = 200; // cron 五段表达式
27
+ const SCHEDULE_RRULE_MAX = 1000; // rrule 两行(DTSTART + RRULE)
27
28
  const SCHEDULE_TIMEZONE_MAX = 64; // IANA 时区名
28
29
  const SCHEDULE_TIME_MAX = 40; // HH:mm:ss.SSS 与 RFC3339 时间戳
29
30
  const SCHEDULE_DEFAULT_LIMIT = 20; // 历史记录默认条数
30
31
  const SCHEDULE_MAX_LIMIT = 100; // 历史记录条数上限(宿主同一口径)
31
32
 
32
- /** 宿主 ScheduleRecord 的封闭 kind 集。 */
33
- const SCHEDULE_KINDS = new Set(['after', 'at', 'every', 'daily', 'weekly', 'cron']);
33
+ /** 宿主 ScheduleRecord 的封闭 kind 集(`rrule` 是 v2 宿主加的)。 */
34
+ const SCHEDULE_KINDS = new Set(['after', 'at', 'every', 'daily', 'weekly', 'cron', 'rrule']);
34
35
 
35
36
  /** 宿主没有这个服务时对 App 的统一回答(不是「没有任务」)。 */
36
37
  const UNAVAILABLE = Object.freeze({
@@ -106,6 +107,32 @@ function normalizeLastDelivery(value) {
106
107
  return { scheduledAt, deliveredAt };
107
108
  }
108
109
 
110
+ /**
111
+ * 一条任务属于哪个会话。
112
+ *
113
+ * v2 宿主把它挪进了 `target`(`session` / `new_once` 带 `sessionId`,`new` 没有),v1 才有行上的
114
+ * `sessionId`;`involvedSessionIds` 是参与过这条任务的会话(建它的那个在前),单会话任务通常就
115
+ * 一条。三处依次退,第一个非空的就是它 —— `new` 目标没有确定会话,退回参与过的那条,
116
+ * 否则这条任务在手机上永远筛不出来(手机是按会话筛的)。
117
+ */
118
+ function entrySessionId(item) {
119
+ const direct = requiredText(item.sessionId, SCHEDULE_ID_MAX);
120
+ if (direct) return direct;
121
+ const target = item.target;
122
+ if (target && typeof target === 'object' && !Array.isArray(target)) {
123
+ const scoped = requiredText(target.sessionId, SCHEDULE_ID_MAX);
124
+ if (scoped) return scoped;
125
+ }
126
+ const involved = item.involvedSessionIds;
127
+ if (Array.isArray(involved)) {
128
+ for (const candidate of involved) {
129
+ const resolved = requiredText(candidate, SCHEDULE_ID_MAX);
130
+ if (resolved) return resolved;
131
+ }
132
+ }
133
+ return '';
134
+ }
135
+
109
136
  /**
110
137
  * 把一条宿主任务压成手机认的 `ScheduleEntry`。
111
138
  *
@@ -122,8 +149,8 @@ function normalizeScheduleEntry(item, sessionId, state) {
122
149
  if (typeof item.kind !== 'string' || !SCHEDULE_KINDS.has(item.kind)) return null;
123
150
  const title = requiredText(item.title, SCHEDULE_TITLE_MAX);
124
151
  if (!title) return null;
125
- // catalog 的 sessionId 在行上,list 的行上没有(由查询串那个顶上)。
126
- const resolvedSessionId = sessionId || requiredText(item.sessionId, SCHEDULE_ID_MAX);
152
+ // catalog 的会话归属在 target / involvedSessionIds 上,list 的由查询串那个顶上。
153
+ const resolvedSessionId = sessionId || entrySessionId(item);
127
154
  if (!resolvedSessionId) return null;
128
155
 
129
156
  const entry = {
@@ -144,6 +171,16 @@ function normalizeScheduleEntry(item, sessionId, state) {
144
171
  if (item.kind === 'after') entry.afterSeconds = seconds;
145
172
  else entry.everySeconds = seconds;
146
173
  }
174
+ } else if (item.kind === 'rrule') {
175
+ // `rrule` 自己带时区(`DTSTART;TZID=Asia/Shanghai:…`),行上没有单独那一格;规则整段
176
+ // 转给 App,时区顺手从 DTSTART 里取出来补上,详情页那一行才不是空的。
177
+ const rrule = optionalText(item.rrule, SCHEDULE_RRULE_MAX);
178
+ if (rrule !== undefined) {
179
+ entry.rrule = rrule;
180
+ const zone = /^DTSTART;TZID=([^:]+):/m.exec(rrule);
181
+ const timeZone = zone === null ? undefined : optionalText(zone[1], SCHEDULE_TIMEZONE_MAX);
182
+ if (timeZone !== undefined) entry.timeZone = timeZone;
183
+ }
147
184
  } else if (item.kind !== 'at') {
148
185
  // `at` 没有专有字段,别把宿主残留的 time / expression 顺手转发出去。
149
186
  const time = optionalText(item.time, SCHEDULE_TIME_MAX);
@@ -186,9 +223,15 @@ function normalizeScheduleEntries(value, sessionId, state) {
186
223
  return out;
187
224
  }
188
225
 
189
- /** 宿主的 `status` → 手机认的 `state`。缺字段时按 active:宁可多显示一条,别整条消失。 */
226
+ /**
227
+ * 宿主的 `status` → 手机认的 `state`。
228
+ *
229
+ * v1 只有 `active` / `inactive`;v2 拆成 `active` / `paused` / `ended` —— 后两个都不是
230
+ * 「还会继续投递」,手机那一页只有两档筛选,都收进 `inactive`。缺字段时按 active:
231
+ * 宁可多显示一条,别整条消失。
232
+ */
190
233
  function stateOf(status) {
191
- return status === 'inactive' ? 'inactive' : 'active';
234
+ return status === 'inactive' || status === 'paused' || status === 'ended' ? 'inactive' : 'active';
192
235
  }
193
236
 
194
237
  /** 错误文字:宿主 reject 的原文,取不到就退回 String(),别回一句空话。 */
@@ -372,6 +415,20 @@ async function handleScheduleHistory(ctx, req, res) {
372
415
  });
373
416
  }
374
417
 
418
+ /**
419
+ * 删一条任务。
420
+ *
421
+ * v1 宿主有独立的 `delete({sessionId, id})`;v2 把它并进了 `edit({id, action:'delete'})`
422
+ * —— 后者只认 id,存储本来就是全局一张表,行上的 sessionId 只是字段(见 A2-57 的坑)。
423
+ * 先问 `edit`,宿主没有这个方法再退回老的 `delete`。
424
+ */
425
+ async function deleteTask(service, sessionId, id) {
426
+ if (typeof service.edit === 'function') {
427
+ return service.edit({ id, action: 'delete' });
428
+ }
429
+ return service.delete({ sessionId, id });
430
+ }
431
+
375
432
  /** POST /api/mobile/v1/schedule/delete —— body { sessionId, id }。 */
376
433
  async function handleScheduleDelete(ctx, req, res) {
377
434
  const body = (await readJsonBody(req)) || {};
@@ -386,7 +443,7 @@ async function handleScheduleDelete(ctx, req, res) {
386
443
 
387
444
  let value;
388
445
  try {
389
- value = await service.delete({ sessionId, id });
446
+ value = await deleteTask(service, sessionId, id);
390
447
  } catch (error) {
391
448
  return sendFailure(res, error);
392
449
  }
@@ -14,6 +14,8 @@
14
14
  * 所以 lib/mobile 下的 import 路径一行都不用改。
15
15
  */
16
16
 
17
+ const path = require('node:path');
18
+
17
19
  const { sendJson, readJsonBody } = require('../shared/http-util');
18
20
  const { isFreshRequest } = require('../shared/fresh-request');
19
21
  const { listRevision, unchangedListBody } = require('../shared/list-revision.js');
@@ -23,6 +25,7 @@ const { withIdempotency, requestIdOf } = require('./idempotency.js');
23
25
  const { listTitle, deriveMessages, lastMessageOf, promptErrorObject, MODEL_UNAVAILABLE_ERROR } = require('./history-derive.js');
24
26
  const { unwrap } = require('./envelope.js');
25
27
  const { callSessions } = require('./rpc.js');
28
+ const { workspaceRoots, isWithin } = require('./allowed-zone.js');
26
29
  const {
27
30
  canonicalDeviceKey, sessionListResponseState, invalidateSessionList, deletedSessionIdsOf,
28
31
  completionGenerationOf, bumpCompletionGeneration,
@@ -336,21 +339,53 @@ async function handleStats(ctx, req, res) {
336
339
  * 的 sessionIds 账上,桌面端侧栏项目下立即可见;无 workspaceId 时回退 cwd
337
340
  * (不传则归属其默认/未分组工作区)。
338
341
  * 走宿主 apiProxy 的 session.create(接受 workspaceId 或 cwd,二者不能同时给)。
342
+ *
343
+ * cwd 进来之前必须落在**宿主已登记的工作区**里:会话的 cwd 就是 allowed-zone 的第二个
344
+ * 根(见 allowed-zone.js 的 allowedRoots),随手塞一个 `/` 进来等于把整台机器变成
345
+ * 「允许读写区」。工作区清单读不到时照实回 502,不按「没有工作区」处理 —— 那会让每个
346
+ * 目录都变成 403,用户看到的是「我的目录突然不能用了」。
339
347
  */
340
348
  async function handleNew(ctx, req, res) {
341
349
  const body = await readJsonBody(req, 64 * 1024);
350
+ const workspaceId = body && typeof body.workspaceId === 'string' && body.workspaceId.trim()
351
+ ? body.workspaceId.trim()
352
+ : '';
353
+ // 只有走 cwd 那条路才需要校验:workspaceId 本身就把范围交给了宿主登记过的那一项。
354
+ let cwd = '';
355
+ if (!workspaceId && body && typeof body.cwd === 'string' && body.cwd.trim()) {
356
+ const declared = body.cwd.trim();
357
+ if (!path.isAbsolute(declared)) {
358
+ return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'cwd 必须是绝对路径' } });
359
+ }
360
+ cwd = path.resolve(declared);
361
+ let roots;
362
+ try {
363
+ roots = await workspaceRoots(ctx);
364
+ } catch (error) {
365
+ return sendJson(res, 502, {
366
+ ok: false,
367
+ error: {
368
+ code: (error && error.code) || 'workspace-list-failed',
369
+ message: String((error && error.message) || error),
370
+ },
371
+ });
372
+ }
373
+ if (!roots.some((root) => isWithin(root, cwd))) {
374
+ return sendJson(res, 403, {
375
+ ok: false,
376
+ error: { code: 'outside-allowed', message: '这个目录不在能建会话的范围里(只能落在已登记的工作区)' },
377
+ });
378
+ }
379
+ }
342
380
  // 新建会话是**非幂等**写:同一发请求被重投一次就会凭空多一个会话。requestId 由
343
381
  // App 在构造请求头时生成一次,内核换通道重投复用同一个 init(同一个头),重投于是
344
382
  // 命中这里、直接拿回第一次的 sessionId(见 mobile/idempotency.js)。
345
383
  const outcome = await withIdempotency(ctx, canonicalDeviceKey(req), requestIdOf(req, body), async () => {
346
384
  let payload = {};
347
- const workspaceId = body && typeof body.workspaceId === 'string' && body.workspaceId.trim()
348
- ? body.workspaceId.trim()
349
- : '';
350
385
  if (workspaceId) {
351
386
  payload.workspaceId = workspaceId;
352
- } else if (body && typeof body.cwd === 'string' && body.cwd.trim()) {
353
- payload.cwd = body.cwd.trim();
387
+ } else if (cwd) {
388
+ payload.cwd = cwd;
354
389
  }
355
390
  // 宿主 session.create 接受 agentPreset:新建时直接定档,省掉「先建再 select」的
356
391
  // 两步(select 只在会话还没开跑时生效,新建后立刻 select 同样可行但多一次 RPC)。