dsh-session-cloud 0.1.0
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/cordis.patch.yml +9 -0
- package/lib/client.js +1912 -0
- package/lib/client.js.map +7 -0
- package/lib/config.d.ts +60 -0
- package/lib/config.js +61 -0
- package/lib/crypto/envelope.d.ts +62 -0
- package/lib/crypto/envelope.js +162 -0
- package/lib/dsh-types.d.ts +69 -0
- package/lib/dsh-types.js +1 -0
- package/lib/index.d.ts +11 -0
- package/lib/index.js +485 -0
- package/lib/status.d.ts +17 -0
- package/lib/status.js +65 -0
- package/lib/sync/engine.d.ts +85 -0
- package/lib/sync/engine.js +379 -0
- package/lib/sync/frames.d.ts +35 -0
- package/lib/sync/frames.js +121 -0
- package/lib/sync/http-client.d.ts +44 -0
- package/lib/sync/http-client.js +87 -0
- package/lib/sync/kdf.d.ts +14 -0
- package/lib/sync/kdf.js +102 -0
- package/lib/sync/restore.d.ts +136 -0
- package/lib/sync/restore.js +295 -0
- package/lib/sync/state.d.ts +89 -0
- package/lib/sync/state.js +117 -0
- package/package.json +62 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Config, INITIAL_STATUS, StatusConfig } from './config.js';
|
|
4
|
+
import { runConnectionCheck } from './status.js';
|
|
5
|
+
import { SyncEngine } from './sync/engine.js';
|
|
6
|
+
import { ProtocolError, SyncClient } from './sync/http-client.js';
|
|
7
|
+
import { bootstrapKdf } from './sync/kdf.js';
|
|
8
|
+
import { countRemoteOnlySessions, decodeMappingDelete, decodeRestoreRequest, deleteMapping, encodeCatalog, encodeMappings, encodeRestoreResult, encodeStartupNotice, fetchCatalog, markRestoredSynced, restoreSessions, } from './sync/restore.js';
|
|
9
|
+
import { DomainStateStore, JsonFileStateStore, cloudSyncDomainSpec } from './sync/state.js';
|
|
10
|
+
export const name = 'cloud-sync';
|
|
11
|
+
export const inject = ['sessionPersistence'];
|
|
12
|
+
export { Config, StatusConfig };
|
|
13
|
+
/**
|
|
14
|
+
* 用户配置字段(引擎重启的最小集合)。通道字段已拆到 'cloud-sync-status'
|
|
15
|
+
* namespace,这里仍保留 configKey 对比作双保险(belt-and-braces)。
|
|
16
|
+
*/
|
|
17
|
+
const USER_CONFIG_FIELDS = [
|
|
18
|
+
'serverUrl',
|
|
19
|
+
'token',
|
|
20
|
+
'passphrase',
|
|
21
|
+
'deviceName',
|
|
22
|
+
'autoUpload',
|
|
23
|
+
'checkOnStartup',
|
|
24
|
+
'uploadDebounceMs',
|
|
25
|
+
'pollIntervalMs',
|
|
26
|
+
'requestTimeoutMs',
|
|
27
|
+
];
|
|
28
|
+
export function configKey(cfg) {
|
|
29
|
+
return JSON.stringify(USER_CONFIG_FIELDS.map((field) => cfg[field]));
|
|
30
|
+
}
|
|
31
|
+
function dshHome(ctx) {
|
|
32
|
+
const fromService = ctx.get('dshHomePath')?.();
|
|
33
|
+
if (fromService)
|
|
34
|
+
return fromService;
|
|
35
|
+
return process.env.DSH_HOME ?? path.join(os.homedir(), '.dsh');
|
|
36
|
+
}
|
|
37
|
+
export async function apply(ctx, config) {
|
|
38
|
+
const logger = ctx.get('logger')?.('cloud-sync') ?? console;
|
|
39
|
+
let current = () => config;
|
|
40
|
+
/** 通道配置('cloud-sync-status' namespace)的活视图;settings 缺失时恒为默认值 */
|
|
41
|
+
let currentStatus = () => INITIAL_STATUS;
|
|
42
|
+
let engine;
|
|
43
|
+
let pollTimer;
|
|
44
|
+
/** session/flush 事件监听的 disposer(引擎停止时移除) */
|
|
45
|
+
let flushHook;
|
|
46
|
+
let stateStore;
|
|
47
|
+
let chain = Promise.resolve();
|
|
48
|
+
/** settings provider 引用(在 ctx.inject 块里捕获);不存在时 writeStatus 为 no-op */
|
|
49
|
+
let settingsProvider;
|
|
50
|
+
/** 上次生效的用户配置 key(configKey 守卫) */
|
|
51
|
+
let lastConfigKey = '';
|
|
52
|
+
/** 已消费的触发器时间戳(消费 = 回写状态字段回显该值) */
|
|
53
|
+
let consumedTestAt = 0;
|
|
54
|
+
let consumedSyncAllAt = 0;
|
|
55
|
+
let consumedRestoreListAt = 0;
|
|
56
|
+
let consumedRestoreAt = 0;
|
|
57
|
+
let consumedMappingDeleteAt = 0;
|
|
58
|
+
async function openState() {
|
|
59
|
+
const storageDomain = ctx.get('storageDomain');
|
|
60
|
+
if (storageDomain) {
|
|
61
|
+
try {
|
|
62
|
+
return new DomainStateStore(await storageDomain.open(cloudSyncDomainSpec));
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
logger.warn(`storageDomain 不可用,降级 JSON 文件 state:${String(error)}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return JsonFileStateStore.open(path.join(dshHome(ctx), 'cloud-sync'));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* kdf 引导(sync/kdf.ts):读本机 sidecar,缺失时跨设备发现他机派生参数
|
|
72
|
+
* (逐个试派生,meta 试解密验证),全部未命中才生成新 salt。
|
|
73
|
+
*/
|
|
74
|
+
async function kdf(client, device) {
|
|
75
|
+
return bootstrapKdf({
|
|
76
|
+
client,
|
|
77
|
+
device,
|
|
78
|
+
passphrase: current().passphrase ?? '',
|
|
79
|
+
onAdopt: (message) => logger.info(`kdf ${message}`),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/** 状态字段回写(Host→Client 通道 'cloud-sync-status')。dispose 后写会抛,吞掉即可。 */
|
|
83
|
+
async function writeStatus(patch) {
|
|
84
|
+
if (!settingsProvider)
|
|
85
|
+
return;
|
|
86
|
+
try {
|
|
87
|
+
await settingsProvider.update('cloud-sync-status', patch);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
logger.warn(`状态回写失败:${String(error)}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function configured(cfg) {
|
|
94
|
+
return Boolean(cfg.serverUrl && cfg.token && cfg.passphrase);
|
|
95
|
+
}
|
|
96
|
+
/** 连通性检查并回写状态;checkedAt 为已消费的 testRequestedAt(boot/配置变更的刷新不传)。 */
|
|
97
|
+
async function checkAndReport(checkedAt) {
|
|
98
|
+
const cfg = current();
|
|
99
|
+
if (!cfg.serverUrl || !cfg.token) {
|
|
100
|
+
// 未配置:按钮本处于禁用态,防御性回写让客户端退出进行态
|
|
101
|
+
await writeStatus({
|
|
102
|
+
statusPhase: 'unconfigured',
|
|
103
|
+
statusCloudCount: 0,
|
|
104
|
+
statusError: '',
|
|
105
|
+
statusErrorDetail: '',
|
|
106
|
+
...(checkedAt === undefined ? {} : { statusCheckedAt: checkedAt }),
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const client = new SyncClient({
|
|
111
|
+
serverUrl: cfg.serverUrl,
|
|
112
|
+
username: cfg.username,
|
|
113
|
+
token: cfg.token,
|
|
114
|
+
timeoutMs: cfg.requestTimeoutMs,
|
|
115
|
+
});
|
|
116
|
+
const result = await runConnectionCheck({
|
|
117
|
+
client,
|
|
118
|
+
device: cfg.deviceName || os.hostname(),
|
|
119
|
+
passphrase: cfg.passphrase ?? '',
|
|
120
|
+
});
|
|
121
|
+
await writeStatus({
|
|
122
|
+
statusPhase: result.phase,
|
|
123
|
+
statusCloudCount: result.cloudCount,
|
|
124
|
+
statusError: result.error,
|
|
125
|
+
statusErrorDetail: result.errorDetail,
|
|
126
|
+
...(checkedAt === undefined ? {} : { statusCheckedAt: checkedAt }),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/** 「立即全部同步」触发器消费(docs/01 §5.3)。 */
|
|
130
|
+
async function runSyncAll(at) {
|
|
131
|
+
const eng = engine;
|
|
132
|
+
if (!eng) {
|
|
133
|
+
// 引擎未运行(配置不齐全):也回显消费值,让客户端退出进行态
|
|
134
|
+
await writeStatus({ statusSyncAllAt: at, statusSyncAllOk: 0, statusSyncAllFailed: 0, statusSyncAllError: '' });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const result = await eng.syncAllNow();
|
|
138
|
+
await writeStatus({
|
|
139
|
+
statusSyncAllAt: at,
|
|
140
|
+
statusSyncAllOk: result.ok,
|
|
141
|
+
statusSyncAllFailed: result.failed,
|
|
142
|
+
statusSyncAllError: result.firstError ?? '',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
// ---- M4:恢复流程(docs/02 §4.3、01 §5.4/§5.5) ----
|
|
146
|
+
function selfDevice() {
|
|
147
|
+
return current().deviceName || os.hostname();
|
|
148
|
+
}
|
|
149
|
+
/** 本机工作区 canonical path 列表(workspaceRegistry 可选注入,缺失即无尾段建议)。 */
|
|
150
|
+
function workspacePaths() {
|
|
151
|
+
return ctx.get('workspaceRegistry')?.list().map((workspace) => workspace.path) ?? [];
|
|
152
|
+
}
|
|
153
|
+
/** 构造恢复链路的 deps;配置不全返回 undefined(调用方回写 unconfigured 回显)。 */
|
|
154
|
+
async function catalogDeps() {
|
|
155
|
+
const cfg = current();
|
|
156
|
+
if (!cfg.serverUrl || !cfg.token || !cfg.passphrase || !stateStore)
|
|
157
|
+
return undefined;
|
|
158
|
+
const client = new SyncClient({ serverUrl: cfg.serverUrl, username: cfg.username, token: cfg.token, timeoutMs: cfg.requestTimeoutMs });
|
|
159
|
+
const { key } = await kdf(client, selfDevice());
|
|
160
|
+
return {
|
|
161
|
+
client,
|
|
162
|
+
key,
|
|
163
|
+
persistence: ctx.sessionPersistence,
|
|
164
|
+
state: stateStore,
|
|
165
|
+
workspacePaths: workspacePaths(),
|
|
166
|
+
// 恢复落位后的 workspace 挂载(web 侧边栏可见性)。复刻 dsh 自己创建会话的
|
|
167
|
+
// 路径(session-controller commands.ts):resolveByPath 复用已有工作区,
|
|
168
|
+
// 无则 create(registry 自带 realpath + isDirectory 校验),再 attachSession。
|
|
169
|
+
// restore 侧已预校验目标目录存在;这里失败时 restoreOne 只告警不回滚
|
|
170
|
+
attachWorkspace: (sessionId, cwd) => {
|
|
171
|
+
const registry = ctx.get('workspaceRegistry');
|
|
172
|
+
if (!registry)
|
|
173
|
+
return Promise.resolve();
|
|
174
|
+
return (async () => {
|
|
175
|
+
const workspace = await registry.resolveByPath(cwd) ?? await registry.create(cwd);
|
|
176
|
+
await workspace.attachSession(sessionId);
|
|
177
|
+
})();
|
|
178
|
+
},
|
|
179
|
+
onWarn: (message) => logger.warn(message),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
/** 「从云端恢复会话…」目录拉取触发器消费。 */
|
|
183
|
+
async function runRestoreList(at) {
|
|
184
|
+
const deps = await catalogDeps().catch((error) => {
|
|
185
|
+
logger.warn(`恢复目录引导失败:${String(error)}`);
|
|
186
|
+
return undefined;
|
|
187
|
+
});
|
|
188
|
+
if (!deps) {
|
|
189
|
+
await writeStatus({ restoreCatalogJson: encodeCatalog({ at, selfDevice: selfDevice(), sessions: [], error: 'unconfigured' }) });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
const sessions = await fetchCatalog(deps);
|
|
194
|
+
await writeStatus({ restoreCatalogJson: encodeCatalog({ at, selfDevice: selfDevice(), sessions, error: '' }) });
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
const code = error instanceof ProtocolError && error.status === 401
|
|
198
|
+
? 'tokenInvalid'
|
|
199
|
+
: error instanceof ProtocolError && error.status === 403 ? 'userMismatch' : 'unreachable';
|
|
200
|
+
await writeStatus({ restoreCatalogJson: encodeCatalog({ at, selfDevice: selfDevice(), sessions: [], error: code }) });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** 「恢复选中会话」请求消费:逐条落位后抑制立刻回传,并回写映射列表与结果。 */
|
|
204
|
+
async function runRestore(request) {
|
|
205
|
+
const deps = await catalogDeps().catch((error) => {
|
|
206
|
+
logger.warn(`恢复引导失败:${String(error)}`);
|
|
207
|
+
return undefined;
|
|
208
|
+
});
|
|
209
|
+
if (!deps) {
|
|
210
|
+
await writeStatus({ restoreResultJson: encodeRestoreResult({ at: request.at, ok: 0, failed: request.items.length, firstError: 'unconfigured' }) });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
const result = await restoreSessions(deps, request.items);
|
|
215
|
+
// 抑制立刻回传:以落位后的当前 revision 记 state(§4.5,见 markRestoredSynced 注释)
|
|
216
|
+
if (result.ok > 0) {
|
|
217
|
+
const restoredIds = request.items.map((item) => item.sessionId);
|
|
218
|
+
await markRestoredSynced(deps, restoredIds).catch((error) => {
|
|
219
|
+
logger.warn(`恢复后 state 标记失败(将导致全量回传一次,不丢数据):${String(error)}`);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
await writeStatus({
|
|
223
|
+
restoreResultJson: encodeRestoreResult({ ...result, at: request.at }),
|
|
224
|
+
pathMappingsJson: encodeMappings(deps.state.getGlobal().pathMappings),
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
await writeStatus({
|
|
229
|
+
restoreResultJson: encodeRestoreResult({ at: request.at, ok: 0, failed: request.items.length, firstError: String(error).slice(0, 300) }),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** 「路径映射」区的删除请求消费。 */
|
|
234
|
+
async function runMappingDelete(from) {
|
|
235
|
+
if (!stateStore)
|
|
236
|
+
return;
|
|
237
|
+
const mappings = await deleteMapping(stateStore, from);
|
|
238
|
+
await writeStatus({ pathMappingsJson: encodeMappings(mappings) });
|
|
239
|
+
}
|
|
240
|
+
/** 启动检查(01 §5.5):配置有效且开关开启时,拉目录数出他机增量并写通知。 */
|
|
241
|
+
async function runStartupCheck() {
|
|
242
|
+
if (!current().checkOnStartup)
|
|
243
|
+
return;
|
|
244
|
+
const deps = await catalogDeps().catch(() => undefined);
|
|
245
|
+
if (!deps)
|
|
246
|
+
return;
|
|
247
|
+
try {
|
|
248
|
+
const count = await countRemoteOnlySessions(deps, selfDevice());
|
|
249
|
+
if (count > 0) {
|
|
250
|
+
await writeStatus({ startupNoticeJson: encodeStartupNotice({ at: Date.now(), count }) });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
logger.warn(`启动检查失败(忽略):${String(error)}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
async function startEngine() {
|
|
258
|
+
const cfg = current();
|
|
259
|
+
if (!cfg.serverUrl || !cfg.token || !cfg.passphrase) {
|
|
260
|
+
logger.info('serverUrl/token/passphrase 未配置齐全,云同步未启动');
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const client = new SyncClient({
|
|
264
|
+
serverUrl: cfg.serverUrl,
|
|
265
|
+
username: cfg.username,
|
|
266
|
+
token: cfg.token,
|
|
267
|
+
timeoutMs: cfg.requestTimeoutMs,
|
|
268
|
+
});
|
|
269
|
+
const device = cfg.deviceName || os.hostname();
|
|
270
|
+
const { key, salt } = await kdf(client, device);
|
|
271
|
+
stateStore ??= await openState();
|
|
272
|
+
const eng = new SyncEngine({
|
|
273
|
+
client,
|
|
274
|
+
key,
|
|
275
|
+
salt,
|
|
276
|
+
device,
|
|
277
|
+
state: stateStore,
|
|
278
|
+
persistence: ctx.sessionPersistence,
|
|
279
|
+
getConfig: () => current(),
|
|
280
|
+
onEvent: (event) => {
|
|
281
|
+
if (event.kind === 'error' || event.kind === 'conflict') {
|
|
282
|
+
logger.warn(`[${event.kind}] ${event.sessionId}: ${event.message ?? ''}`);
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
logger.debug?.(`[${event.kind}] ${event.sessionId} ${event.bytes ?? ''}`);
|
|
286
|
+
}
|
|
287
|
+
// 引擎事件 → 状态字段回写(docs/01 §5.2/§6)
|
|
288
|
+
if (event.kind === 'uploaded') {
|
|
289
|
+
void writeStatus({ statusLastSyncAt: Date.now(), statusError: '', statusErrorDetail: '' });
|
|
290
|
+
}
|
|
291
|
+
else if (event.kind === 'error') {
|
|
292
|
+
void writeStatus({ statusError: 'syncFailed', statusErrorDetail: (event.message ?? '').slice(0, 300) });
|
|
293
|
+
}
|
|
294
|
+
else if (event.kind === 'conflict') {
|
|
295
|
+
// 状态行提示换设备名并附触发会话 id
|
|
296
|
+
void writeStatus({ statusError: 'deviceConflict', statusErrorDetail: event.sessionId });
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
engine = eng;
|
|
301
|
+
pollTimer = setInterval(() => void eng.scan(), current().pollIntervalMs);
|
|
302
|
+
pollTimer.unref?.();
|
|
303
|
+
// session/flush 接线(M5,替代纯轮询的首选变更源):dsh 把缓冲事件落盘的
|
|
304
|
+
// durability checkpoint 是磁盘字节真正变化的时刻,合并窗口后触发一轮
|
|
305
|
+
// 变更发现;轮询兜底保留(恢复落位等不经 live 会话的变更)。
|
|
306
|
+
// listener 只拨 timer,重活留给 scan/防抖队列(flush 是 awaited checkpoint)
|
|
307
|
+
flushHook = ctx.on('session/flush', () => {
|
|
308
|
+
eng.requestScan();
|
|
309
|
+
});
|
|
310
|
+
await eng.start();
|
|
311
|
+
}
|
|
312
|
+
async function stopEngine() {
|
|
313
|
+
if (pollTimer) {
|
|
314
|
+
clearInterval(pollTimer);
|
|
315
|
+
pollTimer = undefined;
|
|
316
|
+
}
|
|
317
|
+
flushHook?.();
|
|
318
|
+
flushHook = undefined;
|
|
319
|
+
const eng = engine;
|
|
320
|
+
engine = undefined;
|
|
321
|
+
await eng?.dispose();
|
|
322
|
+
}
|
|
323
|
+
/** 配置变更:串行重启引擎(installSection 初次也会触发一次 onChange)。
|
|
324
|
+
* 启动失败(如服务器不可达时 kdf 引导被拒)必须就地收容:写 failed 状态 + 日志,
|
|
325
|
+
* 绝不能让拒绝逃逸出 chain —— 否则 cordis 会按 fatal load failure 杀掉整个 dsh 进程。 */
|
|
326
|
+
function restart() {
|
|
327
|
+
chain = chain.then(async () => {
|
|
328
|
+
try {
|
|
329
|
+
await stopEngine();
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
logger.warn(`引擎停止异常(忽略,继续重启):${String(error)}`);
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
await startEngine();
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
logger.warn(`引擎启动失败:${String(error)}`);
|
|
339
|
+
await writeStatus({
|
|
340
|
+
statusPhase: 'failed',
|
|
341
|
+
statusError: 'unreachable',
|
|
342
|
+
statusErrorDetail: String(error).slice(0, 300),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
return chain;
|
|
347
|
+
}
|
|
348
|
+
/** configKey 守卫:只有 9 个用户配置字段变了才重启;状态回写不会触发。 */
|
|
349
|
+
function restartIfConfigChanged() {
|
|
350
|
+
const key = configKey(current());
|
|
351
|
+
if (key === lastConfigKey)
|
|
352
|
+
return undefined;
|
|
353
|
+
lastConfigKey = key;
|
|
354
|
+
return restart();
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* 'cloud-sync'(用户配置)onChange:纠偏 secret 信号(写到 status namespace)→
|
|
358
|
+
* 按需重启引擎。Host 永不写这个 namespace,用户配置写零冲突。
|
|
359
|
+
*/
|
|
360
|
+
function onConfigChange() {
|
|
361
|
+
const cfg = current();
|
|
362
|
+
const status = currentStatus();
|
|
363
|
+
// tokenConfigured/passphraseConfigured 与实际 secret 非空对齐(收敛一轮即停)
|
|
364
|
+
const fixes = {};
|
|
365
|
+
if (status.tokenConfigured !== Boolean(cfg.token))
|
|
366
|
+
fixes.tokenConfigured = Boolean(cfg.token);
|
|
367
|
+
if (status.passphraseConfigured !== Boolean(cfg.passphrase))
|
|
368
|
+
fixes.passphraseConfigured = Boolean(cfg.passphrase);
|
|
369
|
+
if (Object.keys(fixes).length > 0)
|
|
370
|
+
void writeStatus(fixes);
|
|
371
|
+
const restarted = restartIfConfigChanged();
|
|
372
|
+
if (restarted) {
|
|
373
|
+
// 配置变了:重启后补一次连通性检查刷新状态
|
|
374
|
+
void restarted.then(async () => {
|
|
375
|
+
if (configured(current()))
|
|
376
|
+
await checkAndReport();
|
|
377
|
+
}).catch((error) => {
|
|
378
|
+
logger.warn(`配置变更后的连通性检查异常:${String(error)}`);
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* 'cloud-sync-status'(通道)onChange:只消费触发器。
|
|
384
|
+
* Host 自己的状态回写也会触发这里——「大于已消费值才消费」的守卫保证幂等,无循环。
|
|
385
|
+
*/
|
|
386
|
+
function onStatusChange() {
|
|
387
|
+
const status = currentStatus();
|
|
388
|
+
// 触发器消费:只认比已消费值更新的时间戳;回写状态字段再次触发 onChange 时不会重复消费
|
|
389
|
+
if (status.testRequestedAt > consumedTestAt) {
|
|
390
|
+
consumedTestAt = status.testRequestedAt;
|
|
391
|
+
void checkAndReport(consumedTestAt).catch((error) => {
|
|
392
|
+
logger.warn(`连通性检查异常:${String(error)}`);
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
if (status.syncAllRequestedAt > consumedSyncAllAt) {
|
|
396
|
+
consumedSyncAllAt = status.syncAllRequestedAt;
|
|
397
|
+
void runSyncAll(consumedSyncAllAt).catch((error) => {
|
|
398
|
+
logger.warn(`立即全部同步异常:${String(error)}`);
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
if (status.restoreListRequestedAt > consumedRestoreListAt) {
|
|
402
|
+
consumedRestoreListAt = status.restoreListRequestedAt;
|
|
403
|
+
void runRestoreList(consumedRestoreListAt).catch((error) => {
|
|
404
|
+
logger.warn(`恢复目录拉取异常:${String(error)}`);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
const restoreRequest = decodeRestoreRequest(status.restoreRequestJson);
|
|
408
|
+
if (restoreRequest !== undefined && restoreRequest.at > consumedRestoreAt) {
|
|
409
|
+
consumedRestoreAt = restoreRequest.at;
|
|
410
|
+
void runRestore(restoreRequest).catch((error) => {
|
|
411
|
+
logger.warn(`恢复执行异常:${String(error)}`);
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
const mappingDelete = decodeMappingDelete(status.mappingDeleteJson);
|
|
415
|
+
if (mappingDelete !== undefined && mappingDelete.at > consumedMappingDeleteAt) {
|
|
416
|
+
consumedMappingDeleteAt = mappingDelete.at;
|
|
417
|
+
void runMappingDelete(mappingDelete.from).catch((error) => {
|
|
418
|
+
logger.warn(`映射删除异常:${String(error)}`);
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
stateStore = await openState();
|
|
423
|
+
// settings 是可选服务:有则注册两个 namespace(用户配置 + Host↔Client 通道),
|
|
424
|
+
// 配置改动即时生效;无则只用 cordis 层 Config(patch 静态配置)
|
|
425
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
426
|
+
settingsProvider = settingsCtx.settings;
|
|
427
|
+
// 先注册通道 namespace 再注册用户配置:installSection 会同步触发 onChange,
|
|
428
|
+
// onConfigChange 里的 tokenConfigured/passphraseConfigured 对齐回写目标是
|
|
429
|
+
// 'cloud-sync-status'——若它还没注册,回写抛错丢失,徽标会一直停在「未配置」
|
|
430
|
+
settingsCtx.settings?.installSection(ctx, 'cloud-sync-status', StatusConfig, INITIAL_STATUS, {
|
|
431
|
+
setSource: (source) => {
|
|
432
|
+
currentStatus = source;
|
|
433
|
+
},
|
|
434
|
+
onChange: onStatusChange,
|
|
435
|
+
});
|
|
436
|
+
settingsCtx.settings?.installSection(ctx, 'cloud-sync', Config, config, {
|
|
437
|
+
setSource: (source) => {
|
|
438
|
+
current = source;
|
|
439
|
+
},
|
|
440
|
+
onChange: onConfigChange,
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
// 初次启动也走串行 chain:settings 的 onChange 在 installSection 时已触发一次
|
|
444
|
+
// restart(configKey 已记录,这里不重复);settings 缺失时这里补上初次启动
|
|
445
|
+
await restartIfConfigChanged();
|
|
446
|
+
// Boot 状态刷新:配置齐全则跑一次连通性检查;未配置则把状态字段重置为 unconfigured
|
|
447
|
+
// 启动检查(checkOnStartup 的云端增量检查)在连通性确认后进行
|
|
448
|
+
const bootCfg = current();
|
|
449
|
+
try {
|
|
450
|
+
// 已学习映射列表随 boot 下发(设置卡「路径映射」区的数据源)
|
|
451
|
+
if (stateStore) {
|
|
452
|
+
const global = stateStore.getGlobal();
|
|
453
|
+
await writeStatus({ pathMappingsJson: encodeMappings(global.pathMappings) });
|
|
454
|
+
// lastSyncAt 持久化回读(M5):重启后「最近同步」时间不丢
|
|
455
|
+
if (global.lastSyncAt)
|
|
456
|
+
await writeStatus({ statusLastSyncAt: global.lastSyncAt });
|
|
457
|
+
}
|
|
458
|
+
if (configured(bootCfg)) {
|
|
459
|
+
await checkAndReport();
|
|
460
|
+
// 错误摘要持久化(M5):引擎 lastError 存在 state(跨重启)。连通性正常时
|
|
461
|
+
// 回显上次的同步错误(连接错误优先显示);上传成功时引擎会清 lastError
|
|
462
|
+
// 并回写空 statusError,残留窗口到下一轮重试为止
|
|
463
|
+
const global = stateStore?.getGlobal();
|
|
464
|
+
if (global?.lastError && currentStatus().statusError === '') {
|
|
465
|
+
await writeStatus({ statusError: 'syncFailed', statusErrorDetail: global.lastError.slice(0, 300) });
|
|
466
|
+
}
|
|
467
|
+
await runStartupCheck();
|
|
468
|
+
await checkAndReport();
|
|
469
|
+
await runStartupCheck();
|
|
470
|
+
}
|
|
471
|
+
else if (currentStatus().statusPhase !== 'unconfigured') {
|
|
472
|
+
await writeStatus({ statusPhase: 'unconfigured', statusCloudCount: 0, statusError: '', statusErrorDetail: '' });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
logger.warn(`boot 状态刷新异常(忽略):${String(error)}`);
|
|
477
|
+
}
|
|
478
|
+
return async () => {
|
|
479
|
+
chain = chain.then(async () => {
|
|
480
|
+
await stopEngine(); // dispose 内会强制冲刷在途批次
|
|
481
|
+
await stateStore?.close();
|
|
482
|
+
});
|
|
483
|
+
await chain;
|
|
484
|
+
};
|
|
485
|
+
}
|
package/lib/status.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { StatusError, StatusPhase } from './config.js';
|
|
2
|
+
import { type SyncClient } from './sync/http-client.js';
|
|
3
|
+
export interface ConnectionCheckDeps {
|
|
4
|
+
client: SyncClient;
|
|
5
|
+
/** 设备名(云端命名空间段) */
|
|
6
|
+
device: string;
|
|
7
|
+
/** 当前配置的口令;云端为空时不会用到(全新部署只验证地址与令牌) */
|
|
8
|
+
passphrase: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ConnectionCheckResult {
|
|
11
|
+
phase: StatusPhase;
|
|
12
|
+
/** 本设备命名空间下的云端会话数(.meta.enc 对象数) */
|
|
13
|
+
cloudCount: number;
|
|
14
|
+
error: StatusError;
|
|
15
|
+
errorDetail: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function runConnectionCheck(deps: ConnectionCheckDeps): Promise<ConnectionCheckResult>;
|
package/lib/status.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 连通性检查(docs/01 §5.1):测试连接按钮的后台逻辑。
|
|
3
|
+
* 纯逻辑、deps 注入,与 cordis 解耦便于单测;不创建任何云端对象
|
|
4
|
+
* (kdf 引导只在引擎启动路径,这里只读)。
|
|
5
|
+
*/
|
|
6
|
+
import { decodeKdfSidecar, decryptMeta, deriveKey } from './crypto/envelope.js';
|
|
7
|
+
import { ProtocolError } from './sync/http-client.js';
|
|
8
|
+
function ok(cloudCount) {
|
|
9
|
+
return { phase: 'connected', cloudCount, error: '', errorDetail: '' };
|
|
10
|
+
}
|
|
11
|
+
function fail(error, errorDetail = '', cloudCount = 0) {
|
|
12
|
+
return { phase: 'failed', cloudCount, error, errorDetail };
|
|
13
|
+
}
|
|
14
|
+
/** 网络/协议错误分类:401 令牌无效,403 用户名与令牌不配对,其余按不可达。 */
|
|
15
|
+
function classify(error, cloudCount = 0) {
|
|
16
|
+
if (error instanceof ProtocolError) {
|
|
17
|
+
if (error.status === 401)
|
|
18
|
+
return fail('tokenInvalid', String(error.message), cloudCount);
|
|
19
|
+
if (error.status === 403)
|
|
20
|
+
return fail('userMismatch', String(error.message), cloudCount);
|
|
21
|
+
return fail('unreachable', `HTTP ${error.status}: ${error.message}`, cloudCount);
|
|
22
|
+
}
|
|
23
|
+
return fail('unreachable', String(error), cloudCount);
|
|
24
|
+
}
|
|
25
|
+
export async function runConnectionCheck(deps) {
|
|
26
|
+
const { client, device } = deps;
|
|
27
|
+
// 1. 列表探测:通且鉴权过即说明地址与令牌有效
|
|
28
|
+
let objects;
|
|
29
|
+
try {
|
|
30
|
+
objects = await client.list(`sessions/${device}/`);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
return classify(error);
|
|
34
|
+
}
|
|
35
|
+
// 2. 云端会话数 = 设备命名空间下的 meta 对象数
|
|
36
|
+
const metas = objects.filter((o) => o.key.endsWith('.meta.enc'));
|
|
37
|
+
const cloudCount = metas.length;
|
|
38
|
+
// 3. 云端非空时校验口令:kdf sidecar + 首个 meta 试解密
|
|
39
|
+
if (cloudCount > 0) {
|
|
40
|
+
let sidecar;
|
|
41
|
+
try {
|
|
42
|
+
sidecar = await client.download(`keys/${device}/kdf.json`);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
// 会话存在但 kdf sidecar 缺失:无法派生密钥,按口令不匹配处理(只读,绝不创建)
|
|
46
|
+
if (error instanceof ProtocolError && error.status === 404) {
|
|
47
|
+
return fail('passphraseMismatch', `kdf sidecar 缺失(keys/${device}/kdf.json 404)`, cloudCount);
|
|
48
|
+
}
|
|
49
|
+
return classify(error, cloudCount);
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const { salt, params } = decodeKdfSidecar(sidecar);
|
|
53
|
+
const key = deriveKey(deps.passphrase, salt, params);
|
|
54
|
+
const first = await client.download(metas[0].key);
|
|
55
|
+
const sessionId = metas[0].key.slice(`sessions/${device}/`.length, -'.meta.enc'.length);
|
|
56
|
+
decryptMeta(key, sessionId, first);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error instanceof ProtocolError)
|
|
60
|
+
return classify(error, cloudCount);
|
|
61
|
+
return fail('passphraseMismatch', String(error), cloudCount);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return ok(cloudCount);
|
|
65
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { SessionPersistenceLike } from '../dsh-types.js';
|
|
2
|
+
import { type SyncClient } from './http-client.js';
|
|
3
|
+
import type { SessionSyncState, StateStore } from './state.js';
|
|
4
|
+
export interface SyncEngineOptions {
|
|
5
|
+
client: SyncClient;
|
|
6
|
+
/** scrypt 派生后的密钥与 salt(kdf.json 引导完成后再构造引擎) */
|
|
7
|
+
key: Buffer;
|
|
8
|
+
salt: Buffer;
|
|
9
|
+
device: string;
|
|
10
|
+
state: StateStore;
|
|
11
|
+
persistence: SessionPersistenceLike;
|
|
12
|
+
/** 活配置(设置卡改动即时生效) */
|
|
13
|
+
getConfig(): {
|
|
14
|
+
autoUpload: boolean;
|
|
15
|
+
uploadDebounceMs: number;
|
|
16
|
+
};
|
|
17
|
+
/** 状态行/日志出口 */
|
|
18
|
+
onEvent?: (event: SyncEvent) => void;
|
|
19
|
+
now?: () => number;
|
|
20
|
+
}
|
|
21
|
+
export interface SyncEvent {
|
|
22
|
+
kind: 'uploaded' | 'skipped-plain' | 'conflict' | 'error';
|
|
23
|
+
sessionId: string;
|
|
24
|
+
message?: string;
|
|
25
|
+
bytes?: number;
|
|
26
|
+
}
|
|
27
|
+
/** syncAllNow 的结果摘要(docs/01 §5.3:成功 N · 失败 M,附首个错误)。 */
|
|
28
|
+
export interface SyncAllResult {
|
|
29
|
+
ok: number;
|
|
30
|
+
failed: number;
|
|
31
|
+
firstError?: string;
|
|
32
|
+
}
|
|
33
|
+
/** 409 幂等校验不通过:写入方冲突(疑似同名设备的另一台机器)。 */
|
|
34
|
+
export declare class ConflictError extends Error {
|
|
35
|
+
constructor(message: string);
|
|
36
|
+
}
|
|
37
|
+
export declare function logKey(device: string, sessionId: string): string;
|
|
38
|
+
export declare function metaKey(device: string, sessionId: string): string;
|
|
39
|
+
export declare function freshSessionState(): SessionSyncState;
|
|
40
|
+
export declare class SyncEngine {
|
|
41
|
+
private options;
|
|
42
|
+
private scheduled;
|
|
43
|
+
private latest;
|
|
44
|
+
private inFlight;
|
|
45
|
+
private rerunRequested;
|
|
46
|
+
private backoff;
|
|
47
|
+
private scanTimer;
|
|
48
|
+
private disposed;
|
|
49
|
+
private now;
|
|
50
|
+
constructor(options: SyncEngineOptions);
|
|
51
|
+
/**
|
|
52
|
+
* 外部触发的变更发现入口(session/flush 钩子接线,M5):把窗口内的多次
|
|
53
|
+
* 触发合并为一次 scan。listener 必须轻——flush 是 awaited durability
|
|
54
|
+
* checkpoint,重活留给 scan/防抖队列,这里只拨一个 timer。
|
|
55
|
+
*/
|
|
56
|
+
requestScan(): void;
|
|
57
|
+
/** 一轮变更发现:revision 对比,变化的进防抖队列。 */
|
|
58
|
+
scan(): Promise<void>;
|
|
59
|
+
/** 变更判定:revision 与 state 不一致(或处于退避待重试)才需要同步。 */
|
|
60
|
+
private needsSync;
|
|
61
|
+
/**
|
|
62
|
+
* 立即全部同步(设置卡「立即全部同步」按钮,docs/01 §5.3):
|
|
63
|
+
* 用户显式触发,无视 autoUpload 开关;全量扫描后把所有待同步会话
|
|
64
|
+
* 调度进队列并 await 完成(复用 schedule/flush 防抖-冲刷机制)。
|
|
65
|
+
*/
|
|
66
|
+
syncAllNow(): Promise<SyncAllResult>;
|
|
67
|
+
/** 同会话变更在防抖窗口内合并为一次上传。 */
|
|
68
|
+
private schedule;
|
|
69
|
+
/** 进程退出前冲刷:立即执行所有防抖中的会话并等待在途上传结束。 */
|
|
70
|
+
flush(): Promise<void>;
|
|
71
|
+
dispose(): Promise<void>;
|
|
72
|
+
/** 启动:解除 conflict 状态(配置如设备名可能已更换,允许重试),然后做一轮变更发现。 */
|
|
73
|
+
start(): Promise<void>;
|
|
74
|
+
/** 每会话串行泵:在途期间到来的变更置 rerun 标记,结束后立即再跑一轮。 */
|
|
75
|
+
private pump;
|
|
76
|
+
private emit;
|
|
77
|
+
private syncSession;
|
|
78
|
+
private syncSessionOnce;
|
|
79
|
+
/**
|
|
80
|
+
* 409 幂等判定(docs/02 §10):服务端长度恰等于「state 偏移 + 待传段长」时,
|
|
81
|
+
* 下载末段做链式 AAD 校验并比对明文——通过则视为上次上传已成功(不重复上传);
|
|
82
|
+
* 否则判定写入方冲突,抛 ConflictError 停止该会话同步,不做盲目全量覆写。
|
|
83
|
+
*/
|
|
84
|
+
private resolve409;
|
|
85
|
+
}
|