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 +30 -0
- package/lib/client.js +20 -6
- package/package.json +1 -1
- package/src/cindy-channels.js +9 -0
- package/src/host-authorization.js +3 -1
- package/src/host-device-delete.js +50 -0
- package/src/host-routes.js +38 -16
- package/src/host-status.js +3 -1
- package/src/host-version.js +5 -0
- package/src/host.js +41 -2
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: "
|
|
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
package/src/cindy-channels.js
CHANGED
|
@@ -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:
|
|
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
|
+
}
|
package/src/host-routes.js
CHANGED
|
@@ -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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
: '';
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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.
|
package/src/host-status.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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(),
|