dsh-cindy-host 0.1.23 → 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,23 @@
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
+
6
23
  ## [0.1.23] - 2026-10-14
7
24
 
8
25
  ### 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.23",
3
+ "version": "0.1.25",
4
4
  "private": false,
5
5
  "description": "Cindy 手机客户端连接 DSH 的 Host 侧插件:会话/消息/审批/todo/goal 的手机通道,以及配套的设置卡片",
6
6
  "license": "MIT",
@@ -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(),