dsh-cindy-host 0.1.22 → 0.1.25

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/CHANGELOG.md CHANGED
@@ -3,6 +3,36 @@
3
3
  本文件记录**用户可见**的变化与**每次发布验证过的 DSH 版本**。格式遵循
4
4
  [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
5
5
 
6
+ ## [0.1.25] - 2026-10-22
7
+
8
+ ### Fixed
9
+
10
+ - 设备删除遇到令牌过期时使用 Host 共享凭据刷新通道,最多重试一次;保留登录态,并在重试前重新检查设备在线状态。
11
+ - 按上游实际 HTTP 状态区分登录过期、权限、设备不存在、冲突和服务故障,不再统一误报“设备可能已上线”。
12
+ - 删除请求补齐官方客户端使用的 JSON Content-Type;Cindy 兼容版本仍为 0.1.82。
13
+
14
+ ## [0.1.24] - 2026-10-20
15
+
16
+ ### Fixed
17
+
18
+ - 设置页支持二次确认删除账号中的离线设备;拒绝删除在线设备和当前 Host,删除失败不清除登录态。
19
+ - 设备身份读取失败时停止注册而非生成新身份,并合并并发身份初始化,避免插件制造重复设备。
20
+ - Cindy 兼容版本统一为真实桌面稳定版 0.1.82(2026-09-14),与插件发行版本独立。
21
+ - 基于正式 0.1.23 集成,保留会话删除确认、交互与设置兼容能力。
22
+
23
+ ## [0.1.23] - 2026-10-14
24
+
25
+ ### Fixed
26
+
27
+ - 手机删除已不存在的 DSH 会话时,在删除标记持久化成功后返回最小删除确认,重复删除也成功,避免手机因 NOT_FOUND 回滚而留下幽灵任务。
28
+ - 仅放宽纯删除元数据请求(可同时取消置顶);归档、重命名和混合编辑仍保留 NOT_FOUND。保存或读取失败不冒充删除成功,不伪造完整会话。
29
+
30
+ ### 验证
31
+
32
+ - 完整测试:770 通过 / 3 跳过 / 0 失败;另设置 CINDY_SOURCE_ROOT 补跑其中 2 项真实双端契约测试,均通过。剩余跳过项为需显式启用的 11 分钟慢上传测试。
33
+ - 通道审计:54 served / 142 declined / 0 unclassified。干净 DSH 0.2.0-rc.2 隔离 profile 加载 tarball 成功,HTTP 连续两次删除不存在会话均返回成功;未构建原生凭据依赖,未登录账号。
34
+ - 旧版整体验收 67/77:存在账号/历史数据前置条件缺失及旧协议断言,不计为全绿。用户明确接受本次专项验证范围后发布;手机端实测及真实宿主重启生效仍需单独确认。
35
+
6
36
  ## [0.1.22] - 2026-10-03
7
37
 
8
38
  ### Fixed
package/lib/client.js CHANGED
@@ -64,7 +64,7 @@ window.__ModuleLoader__.load({
64
64
  signIn: "登录",
65
65
  signingIn: "登录中…",
66
66
  logout: "退出登录",
67
- devices: "已连接设备",
67
+ devices: "账号设备",
68
68
  deviceOffline: "离线",
69
69
  deviceOnline: "在线",
70
70
  unknownPlatform: "未知平台",
@@ -101,7 +101,7 @@ window.__ModuleLoader__.load({
101
101
  signIn: "Sign in",
102
102
  signingIn: "Signing in…",
103
103
  logout: "Sign out",
104
- devices: "Connected devices",
104
+ devices: "Account devices",
105
105
  deviceOffline: "offline",
106
106
  deviceOnline: "online",
107
107
  unknownPlatform: "unknown platform",
@@ -451,7 +451,8 @@ window.__ModuleLoader__.load({
451
451
  }
452
452
 
453
453
  /** One device row: a name the user recognizes plus the id they can quote. */
454
- function DeviceRow({ device }) {
454
+ function DeviceRow({ device, onDelete, busy = false }) {
455
+ const [confirming, setConfirming] = react.useState(false);
455
456
  const online = device.online === true;
456
457
  return h(
457
458
  "div",
@@ -460,8 +461,15 @@ window.__ModuleLoader__.load({
460
461
  h(
461
462
  "span",
462
463
  { style: styles.deviceMeta },
463
- (online ? TEXT.deviceOnline : TEXT.deviceOffline) + " · " + platformText(device.platform) + " · " + device.deviceId,
464
+ (online ? TEXT.deviceOnline : TEXT.deviceOffline) + " · " + platformText(device.platform) + " · " + device.deviceId + (device.lastSeenAt ? " · " + device.lastSeenAt : ""),
464
465
  ),
466
+ onDelete ? h("div", null,
467
+ confirming && device.online === false ? h("div", null,
468
+ h("span", { style: styles.hint }, "从账号中删除此设备?这会移除该设备记录,并非仅隐藏。"),
469
+ h("button", { type: "button", disabled: busy, onClick: () => onDelete(device.deviceId) }, "确认删除"),
470
+ h("button", { type: "button", disabled: busy, onClick: () => setConfirming(false) }, "取消"),
471
+ ) : h("button", { type: "button", disabled: busy || device.online !== false, title: online ? "在线设备不能删除,请先断开" : "删除离线设备", onClick: () => setConfirming(true) }, "删除设备"),
472
+ ) : null,
465
473
  );
466
474
  }
467
475
 
@@ -533,6 +541,12 @@ window.__ModuleLoader__.load({
533
541
  }
534
542
  }, [refresh]);
535
543
 
544
+ const onDeleteDevice = (deviceId) => guard(async () => {
545
+ const result = await postJson("/devices/delete", { deviceId });
546
+ if (!result.ok) { setError(result.message || "删除失败,请刷新重试"); return; }
547
+ if (result.status) setStatus(result.status);
548
+ });
549
+
536
550
  const onSendCode = ({ kind, identifier }) =>
537
551
  guard(async () => {
538
552
  setNotice(null);
@@ -647,12 +661,12 @@ window.__ModuleLoader__.load({
647
661
  "div",
648
662
  { style: styles.deviceList },
649
663
  h("span", { style: styles.label }, TEXT.devices),
650
- view.devices.length > 0 ? view.devices.map((device) => h(DeviceRow, { key: device.deviceId, device })) : h("span", { style: styles.hint }, TEXT.noDevices),
664
+ view.devices.length > 0 ? view.devices.map((device) => h(DeviceRow, { key: device.deviceId, device, busy, onDelete: view.login.authenticated && device.deviceId !== view.host?.deviceId ? onDeleteDevice : null })) : h("span", { style: styles.hint }, TEXT.noDevices),
651
665
  view.host && view.host.deviceId
652
666
  ? h(
653
667
  "span",
654
668
  { style: styles.deviceMeta },
655
- TEXT.hostDevice + ": " + (view.host.deviceName || "DSH Host") + " · " + view.host.deviceId,
669
+ TEXT.hostDevice + ": " + (view.host.deviceName || "DSH Host") + " · " + view.host.deviceId + (view.host.appVersion ? " · Cindy " + view.host.appVersion : ""),
656
670
  )
657
671
  : null,
658
672
  ),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cindy-host",
3
- "version": "0.1.22",
3
+ "version": "0.1.25",
4
4
  "private": false,
5
5
  "description": "Cindy 手机客户端连接 DSH 的 Host 侧插件:会话/消息/审批/todo/goal 的手机通道,以及配套的设置卡片",
6
6
  "license": "MIT",
@@ -1041,6 +1041,7 @@ export function createChannelRouter({
1041
1041
  wrote = true;
1042
1042
  }
1043
1043
 
1044
+ let deleteAcknowledgement;
1044
1045
  const writesMeta = patch.status !== undefined || patch.pinnedAt !== undefined;
1045
1046
  if (writesMeta) {
1046
1047
  const applyFlags = capabilities.applySessionFlags;
@@ -1053,6 +1054,13 @@ export function createChannelRouter({
1053
1054
  const echoed = {};
1054
1055
  if (patch.status !== undefined) echoed.status = applied.status;
1055
1056
  if (patch.pinnedAt !== undefined) echoed.pinnedAt = applied.pinnedAt;
1057
+ // Deleting an already absent source session is still a successful hide once
1058
+ // its flags are saved. The phone needs only the fields it wrote; do not
1059
+ // fabricate a full session or extend this acknowledgement to mixed edits.
1060
+ if (patch.status === 'deleted' && applied.status === 'deleted'
1061
+ && Object.keys(patch).every((key) => key === 'status' || key === 'pinnedAt')) {
1062
+ deleteAcknowledgement = { id: sessionId, ...echoed };
1063
+ }
1056
1064
  if (typeof capabilities.publishSessionMeta === 'function') capabilities.publishSessionMeta(sessionId, echoed);
1057
1065
  }
1058
1066
 
@@ -1061,6 +1069,7 @@ export function createChannelRouter({
1061
1069
  // unchanged row as "revert my edit" (that is how these buttons looked dead once already).
1062
1070
  if (wrote) invalidateSessionRows();
1063
1071
  const row = await sessionRow(sessionId);
1072
+ if (row === undefined && deleteAcknowledgement !== undefined) return invokeResult(request, deleteAcknowledgement);
1064
1073
  return row === undefined ? invokeError(request, 'NOT_FOUND', `No DSH session ${sessionId}`) : invokeResult(request, row);
1065
1074
  }
1066
1075
 
@@ -14,6 +14,8 @@
14
14
  * which is exactly the trap this file now documents.
15
15
  */
16
16
 
17
+ import { HOST_APP_VERSION } from './host-version.js';
18
+
17
19
  const CHANNELS = ['device-link:subscribe', 'local-db:sessions:list'];
18
20
 
19
21
  /**
@@ -43,7 +45,7 @@ export function acceptLink(frame, acceptedControllers, capabilities = HOST_CAPAB
43
45
  id: frame.id,
44
46
  dst: frame.src,
45
47
  payload: {
46
- appVersion: '0.1.0',
48
+ appVersion: HOST_APP_VERSION,
47
49
  allowlistHash: computeAllowlistHash(),
48
50
  ...(capabilities.length === 0 ? {} : { capabilities: [...capabilities] }),
49
51
  },
@@ -0,0 +1,50 @@
1
+ /** Delete only a known offline peer; credential renewal belongs to the Host owner. */
2
+ export async function deleteAccountDevice({ deviceId, session, snapshot, url, fetchImpl, refreshSession, getSnapshot = () => snapshot }) {
3
+ const fail = (statusCode, message) => ({ ok: false, statusCode, message });
4
+ const validate = () => {
5
+ if (typeof deviceId !== 'string' || !deviceId.trim()) return fail(400, '设备 ID 无效');
6
+ if (!session?.accessToken) return fail(401, '现有登录态不可用,请检查凭据存储');
7
+ const current = getSnapshot();
8
+ if (deviceId === session.deviceId || deviceId === current.host?.deviceId) return fail(409, '不能删除当前主机');
9
+ const device = current.devices.find(row => row.deviceId === deviceId);
10
+ if (!device) return fail(404, '设备不在当前列表中,请刷新');
11
+ if (device.online !== false) return fail(409, '在线设备不能删除,请先将该设备断开');
12
+ return null;
13
+ };
14
+ const invalid = validate();
15
+ if (invalid) return invalid;
16
+ try {
17
+ for (let attempt = 0; attempt < 2; attempt++) {
18
+ const response = await fetchImpl(url + '/' + encodeURIComponent(deviceId), {
19
+ method: 'DELETE', headers: { Authorization: 'Bearer ' + session.accessToken, accept: 'application/json', 'Content-Type': 'application/json' },
20
+ signal: AbortSignal.timeout(15000),
21
+ });
22
+ const body = await response.json().catch(() => null);
23
+ if (response.status === 401 && attempt === 0 && refreshSession) {
24
+ const renewed = await refreshSession(session);
25
+ if (renewed) {
26
+ session = renewed;
27
+ const invalid = validate();
28
+ if (invalid) return invalid;
29
+ continue;
30
+ }
31
+ }
32
+ if (!response.ok) {
33
+ const messages = {
34
+ 401: '登录态已过期,未删除设备,也未清除凭据',
35
+ 403: '服务拒绝删除:当前账号无权删除此设备',
36
+ 404: '服务未找到该设备,请刷新设备列表',
37
+ 405: '当前服务不支持设备删除接口',
38
+ 409: '服务拒绝删除:设备状态冲突,请刷新设备列表',
39
+ 429: '删除请求过于频繁,请稍后重试',
40
+ };
41
+ const code = response.status >= 400 && response.status <= 599 ? response.status : 502;
42
+ return fail(code, messages[code] ?? ('设备删除服务请求失败(HTTP ' + code + '),请稍后重试'));
43
+ }
44
+ if (body?.deleted !== true || body.deviceId !== deviceId) return fail(502, '服务未确认删除,请刷新设备列表');
45
+ return { ok: true, deviceId, deleted: true };
46
+ }
47
+ } catch {
48
+ return fail(502, '无法确认删除结果,请检查网络并刷新列表');
49
+ }
50
+ }
@@ -79,23 +79,29 @@ export function createHostRoutes({ getRuntime, getDiagnostics, getSettings, reme
79
79
  * different device and makes the phone's existing link dangle.
80
80
  * @returns the device id to log in with.
81
81
  */
82
+ let pendingDeviceId = null;
83
+ let fallbackDeviceId = null;
82
84
  async function hostDeviceId() {
83
- const stored = await loadCredential().catch(() => null);
84
- const fromCredential = typeof stored?.deviceId === 'string' ? stored.deviceId.trim() : '';
85
- const remembered = typeof getSettings === 'function'
86
- ? (() => { try { return rememberedDeviceId(getSettings()); } catch { return ''; } })()
87
- : '';
88
- const deviceId = fromCredential !== '' ? fromCredential : (remembered !== '' ? remembered : randomUUID());
89
- if (deviceId !== remembered && typeof rememberDeviceId === 'function') {
90
- // Remembering is best effort: a settings write that fails must not cost the
91
- // login the user is in the middle of.
92
- try {
93
- await rememberDeviceId(deviceId);
94
- } catch {
95
- // Ignored on purpose.
85
+ if (pendingDeviceId) return pendingDeviceId;
86
+ pendingDeviceId = (async () => {
87
+ // A read failure is NOT a missing identity. Never create a replacement then.
88
+ const stored = await loadCredential();
89
+ const fromCredential = typeof stored?.deviceId === 'string' ? stored.deviceId.trim() : '';
90
+ const remembered = typeof getSettings === 'function' ? rememberedDeviceId(getSettings()) : '';
91
+ const existing = fromCredential || remembered || fallbackDeviceId;
92
+ const deviceId = existing || randomUUID();
93
+ if (deviceId !== remembered && typeof rememberDeviceId === 'function') {
94
+ try { await rememberDeviceId(deviceId); }
95
+ catch (error) {
96
+ // A new ID must be durable before it is registered remotely.
97
+ if (!existing) throw error;
98
+ }
96
99
  }
97
- }
98
- return deviceId;
100
+ fallbackDeviceId = deviceId;
101
+ return deviceId;
102
+ })();
103
+ try { return await pendingDeviceId; }
104
+ finally { pendingDeviceId = null; }
99
105
  }
100
106
 
101
107
  /** Why the Host has (or has not) any data to serve — cheap, and only for diagnosis. */
@@ -212,7 +218,23 @@ export function createHostRoutes({ getRuntime, getDiagnostics, getSettings, reme
212
218
  * The route table: one entry per accepted method+path.
213
219
  * @returns handlers keyed by method and pathname.
214
220
  */
221
+ async function handleDeleteDevice(req, res, body) {
222
+ // A cross-origin HTML form cannot issue this JSON-only destructive request.
223
+ if (!(req.headers?.['content-type'] ?? '').toLowerCase().startsWith('application/json') || req.headers?.['sec-fetch-site'] === 'cross-site') {
224
+ sendJson(res, 403, { ok: false, message: '删除设备需要同源 JSON 请求' });
225
+ return;
226
+ }
227
+ const runtime = getRuntime();
228
+ if (typeof runtime?.deleteDevice !== 'function') {
229
+ sendJson(res, 503, { ok: false, message: 'Cindy Host 运行时尚未就绪' });
230
+ return;
231
+ }
232
+ const result = await runtime.deleteDevice(body.deviceId);
233
+ sendJson(res, result.ok ? 200 : (result.statusCode ?? 502), { ...result, status: runtime.getStatus() });
234
+ }
235
+
215
236
  const table = new Map([
237
+ ['POST /devices/delete', handleDeleteDevice],
216
238
  ['GET /status', handleStatus],
217
239
  ['POST /reconnect', handleReconnect],
218
240
  ['POST /login/request-code', handleRequestCode],
@@ -223,7 +245,7 @@ export function createHostRoutes({ getRuntime, getDiagnostics, getSettings, reme
223
245
  ]);
224
246
 
225
247
  /** Paths that take a JSON body. */
226
- const BODY_PATHS = new Set(['/reconnect', '/login/request-code', '/login/verify-code', '/login/select-account', '/logout', '/selftest']);
248
+ const BODY_PATHS = new Set(['/devices/delete', '/reconnect', '/login/request-code', '/login/verify-code', '/login/select-account', '/logout', '/selftest']);
227
249
 
228
250
  /**
229
251
  * The one handler registered on the web server.
@@ -21,6 +21,8 @@
21
21
  * never shows a terminal failure for an ordinary network loss.
22
22
  */
23
23
 
24
+ import { HOST_APP_VERSION } from './host-version.js';
25
+
24
26
  /** Ordered state vocabulary; also the wire vocabulary of the status route. */
25
27
  export const CONNECTION_STATES = Object.freeze(['disconnected', 'authenticating', 'connecting', 'waiting', 'connected', 'failed']);
26
28
 
@@ -149,7 +151,7 @@ export class HostStatus {
149
151
  return;
150
152
  }
151
153
  this.#ownDeviceId = typeof host.deviceId === 'string' ? host.deviceId : null;
152
- this.#publish({ host: { deviceName: 'DSH Host', platform: 'desktop', appVersion: '0.1.0', online: true, protocolVersion: null, userId: null, ...host } });
154
+ this.#publish({ host: { deviceName: 'DSH Host', platform: 'desktop', appVersion: HOST_APP_VERSION, online: true, protocolVersion: null, userId: null, ...host } });
153
155
  }
154
156
 
155
157
  /** Insert or merge one device row, preserving fields the patch omits. */
@@ -0,0 +1,5 @@
1
+ /** Cindy desktop compatibility version, NOT this plugin's package version.
2
+ * Source: G:/Projects/Cindy tag v0.1.82, latest non-beta September 2026 tag.
3
+ * Commit 1d7aaa2fb4456c1ba96ab7f2125567d7c19bbf69, 2026-09-14T20:53:42+07:00.
4
+ */
5
+ export const HOST_APP_VERSION = '0.1.82';
package/src/host.js CHANGED
@@ -71,7 +71,9 @@ export const HEARTBEAT_MISS_LIMIT = 3;
71
71
 
72
72
  export const HOST_DEVICE_NAME = 'DSH Host';
73
73
  export const HOST_PLATFORM = 'desktop';
74
- export const HOST_APP_VERSION = '0.1.0';
74
+ import { deleteAccountDevice } from './host-device-delete.js';
75
+ import { HOST_APP_VERSION } from './host-version.js';
76
+ export { HOST_APP_VERSION } from './host-version.js';
75
77
 
76
78
  /** Default socket factory; tests inject their own. */
77
79
  function openRelaySocket(session, url = RELAY_WS_URL) {
@@ -808,16 +810,52 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
808
810
  * fixes every such row at once; failures are silent because a missing name
809
811
  * only costs a nicer label, never a connection.
810
812
  */
813
+ let directoryEpoch = 0;
814
+ const pendingDeletes = new Set();
815
+ async function deleteDevice(deviceId) {
816
+ if (pendingDeletes.has(deviceId)) return { ok: false, statusCode: 409, message: '设备正在删除' };
817
+ let owner = session;
818
+ const epoch = generation;
819
+ if (stopped || !active) return { ok: false, statusCode: 409, message: '请先连接 Cindy Host' };
820
+ pendingDeletes.add(deviceId);
821
+ try {
822
+ const result = await deleteAccountDevice({
823
+ deviceId, session: owner, snapshot: status.snapshot(),
824
+ getSnapshot: () => status.snapshot(), url: deviceListUrl(relayUrl), fetchImpl,
825
+ refreshSession: async captured => {
826
+ if (stopped || !active || generation !== epoch || session !== captured) return null;
827
+ const refreshed = await refreshSessionForMedia();
828
+ if (!refreshed || stopped || !active || generation !== epoch ||
829
+ !session || session.deviceId !== captured.deviceId || session.authBaseUrl !== captured.authBaseUrl) return null;
830
+ owner = session;
831
+ return session;
832
+ },
833
+ });
834
+ if (stopped || generation !== epoch || session !== owner) return { ok: false, statusCode: 409, message: '连接身份已改变,请刷新设备列表' };
835
+ if (result.ok) {
836
+ directoryEpoch++;
837
+ acceptedControllers.delete(deviceId);
838
+ subscribers.delete(deviceId);
839
+ status.removeDevice(deviceId);
840
+ status.refreshConnectionState();
841
+ }
842
+ return result;
843
+ } finally {
844
+ pendingDeletes.delete(deviceId);
845
+ }
846
+ }
847
+
811
848
  async function refreshDeviceDirectory() {
812
849
  if (session === null) return;
813
850
  const myGeneration = generation;
851
+ const readEpoch = directoryEpoch;
814
852
  let answer;
815
853
  try {
816
854
  answer = await listDevices(session, relayUrl);
817
855
  } catch {
818
856
  return;
819
857
  }
820
- if (stopped || !active || myGeneration !== generation) return;
858
+ if (stopped || !active || myGeneration !== generation || readEpoch !== directoryEpoch) return;
821
859
  const rows = Array.isArray(answer?.devices) ? answer.devices : [];
822
860
  for (const row of rows) {
823
861
  if (row === null || typeof row !== 'object') continue;
@@ -2255,6 +2293,7 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
2255
2293
  policy,
2256
2294
  status,
2257
2295
  getStatus: () => status.snapshot(),
2296
+ deleteDevice,
2258
2297
  subscribeStatus: (listener) => status.subscribe(listener),
2259
2298
  /** The most recent invokes this Host served, for the status route's diagnostics. */
2260
2299
  getInvokeLog: () => invokeLog.slice(),