@walkhi/code-relax 0.1.0-beta.27 → 0.1.0-beta.29
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/README.md +80 -67
- package/dist/bin/codex-remote.mjs +303 -239
- package/dist/bin/self-relay-server.mjs +2 -0
- package/dist/shared/app-server-events.cjs +102 -16
- package/dist/shared/code-browser.cjs +159 -0
- package/dist/shared/file-changes.cjs +31 -0
- package/dist/shared/file-citations.cjs +117 -82
- package/dist/shared/file-mentions.cjs +80 -35
- package/dist/shared/message-delivery.cjs +38 -0
- package/dist/shared/p2p-file-channel.cjs +74 -0
- package/dist/shared/server-requests.cjs +149 -41
- package/dist/shared/service-endpoints.cjs +11 -11
- package/dist/shared/timeline-sync.cjs +173 -156
- package/dist/skills/code-relax/SKILL.md +2 -1
- package/dist/src/bounded-process-log.mjs +63 -0
- package/dist/src/celsius-client.mjs +107 -0
- package/dist/src/code-browser.mjs +101 -0
- package/dist/src/desktop-launcher.mjs +30 -30
- package/dist/src/dots-approval-timeline.mjs +99 -0
- package/dist/src/dots-avatar.mjs +42 -0
- package/dist/src/dots-chat-projection.mjs +50 -0
- package/dist/src/dots-chat-server.mjs +412 -0
- package/dist/src/dots-message-images.mjs +319 -0
- package/dist/src/dots-profile.mjs +77 -0
- package/dist/src/dots-room-activity.mjs +94 -0
- package/dist/src/dots-room-client.mjs +135 -0
- package/dist/src/dots-room-projection.mjs +94 -0
- package/dist/src/dots-schedules.mjs +66 -0
- package/dist/src/dots-summary-tasks.mjs +32 -0
- package/dist/src/dots-task-catalog.mjs +411 -0
- package/dist/src/file-transfer-range.mjs +22 -0
- package/dist/src/file-upload-session.mjs +130 -124
- package/dist/src/hosted-history-app-server.mjs +103 -0
- package/dist/src/hosted-history-cache.mjs +113 -0
- package/dist/src/lan-socket-server.mjs +205 -160
- package/dist/src/managed-app-server.mjs +241 -238
- package/dist/src/message-upload-files.mjs +74 -0
- package/dist/src/platform/windows/activate-desktop.ps1 +48 -0
- package/dist/src/platform/windows/codex-runtime.mjs +253 -251
- package/dist/src/platform/windows/desktop-host.mjs +38 -32
- package/dist/src/platform/windows/desktop-info.ps1 +2 -2
- package/dist/src/platform/windows/desktop-project-registration.mjs +153 -0
- package/dist/src/platform/windows/dots-profile.mjs +49 -0
- package/dist/src/platform/windows/launch-worker.mjs +41 -42
- package/dist/src/platform/windows/project-locations.mjs +16 -0
- package/dist/src/project-create.mjs +119 -0
- package/dist/src/project-management.mjs +69 -0
- package/dist/src/self-relay/account-connections.mjs +52 -0
- package/dist/src/self-relay/admin-page.mjs +80 -80
- package/dist/src/self-relay/admin-style.mjs +14 -9
- package/dist/src/self-relay/connector.mjs +23 -5
- package/dist/src/self-relay/email-accounts.mjs +35 -10
- package/dist/src/self-relay/file-wire.mjs +13 -0
- package/dist/src/self-relay/lifecycle.mjs +22 -4
- package/dist/src/self-relay/long-pairing.mjs +40 -0
- package/dist/src/self-relay/p2p-probe.mjs +152 -123
- package/dist/src/self-relay/rtc-file-bridge.mjs +41 -0
- package/dist/src/self-relay/server.mjs +922 -849
- package/dist/src/server.mjs +474 -199
- package/dist/src/service-control.mjs +13 -3
- package/dist/src/service-doctor.mjs +72 -61
- package/dist/src/service-lifecycle.mjs +51 -43
- package/dist/src/shared-app-server.mjs +166 -107
- package/dist/src/shared-catalog.mjs +132 -40
- package/dist/src/shared-thread-stream.mjs +103 -25
- package/dist/src/usage-reset.mjs +17 -0
- package/dist/src/workstation-downloads.mjs +120 -0
- package/package.json +1 -1
|
@@ -1,86 +1,164 @@
|
|
|
1
1
|
// Event-driven subscription adapter. Normal desktop-tool polling remains in its own adapter.
|
|
2
2
|
import timelineSync from '../shared/timeline-sync.cjs';
|
|
3
|
-
const threadListNotificationMethods = new Set([
|
|
3
|
+
export const threadListNotificationMethods = new Set([
|
|
4
4
|
'thread/started', 'thread/status/changed', 'thread/name/updated',
|
|
5
5
|
'thread/archived', 'thread/unarchived', 'thread/deleted',
|
|
6
6
|
'thread/project/updated', 'project/changed', 'turn/started', 'turn/completed',
|
|
7
7
|
]);
|
|
8
8
|
export async function streamSharedThread(response, threadId, client, normalize, requestedSync) {
|
|
9
9
|
response.startEvents();
|
|
10
|
-
let closed = false, timer, subscription, streamReady = false, flushing = false;
|
|
10
|
+
let closed = false, timer, subscription, releaseActivity, streamReady = false, flushing = false;
|
|
11
|
+
let tracePending = false, pendingReset;
|
|
11
12
|
const pending = new Map(), sentTurns = new Map();
|
|
12
13
|
let fromSeq, toSeq, syncId = requestedSync?.id, epoch = requestedSync?.epoch;
|
|
13
14
|
let finish;
|
|
14
15
|
const done = new Promise(resolve => { finish = resolve; });
|
|
15
16
|
const emit = (name, value) => { if (!closed) return response.event(name, value); };
|
|
17
|
+
let historySync;
|
|
18
|
+
const publishHistorySync = () => {
|
|
19
|
+
if (typeof client.isHistorySyncing !== 'function') return;
|
|
20
|
+
const active = client.isHistorySyncing(threadId);
|
|
21
|
+
// End the notice only after the recovered body has been delivered, not
|
|
22
|
+
// when the upstream read finishes while its update is still being flushed.
|
|
23
|
+
if (!active && (!streamReady || flushing || pendingReset || pending.size)) return;
|
|
24
|
+
if (historySync === active) return;
|
|
25
|
+
historySync = active;
|
|
26
|
+
emit('history-sync', { threadId, active });
|
|
27
|
+
};
|
|
16
28
|
const flush = async () => {
|
|
17
29
|
clearTimeout(timer); timer = null;
|
|
18
30
|
const record = client.threads.get(threadId);
|
|
19
|
-
if (closed || flushing || !streamReady || !record || !pending.size) return;
|
|
31
|
+
if (closed || flushing || !streamReady || !record || (!pendingReset && !pending.size)) return;
|
|
20
32
|
flushing = true;
|
|
21
33
|
try {
|
|
22
|
-
|
|
34
|
+
if (pendingReset) {
|
|
35
|
+
const snapshot = pendingReset;
|
|
36
|
+
pendingReset = undefined;
|
|
37
|
+
for (const [id, change] of pending) if (change.sequence <= snapshot.sync.seq) pending.delete(id);
|
|
38
|
+
fromSeq = pending.size ? Math.max(fromSeq, snapshot.sync.seq + 1) : undefined;
|
|
39
|
+
if (!pending.size) toSeq = undefined;
|
|
40
|
+
tracePending = [...pending.values()].some(change => change.immediate);
|
|
41
|
+
const normalized = await normalize(snapshot);
|
|
42
|
+
if (closed) return;
|
|
43
|
+
await emit('update', normalized);
|
|
44
|
+
syncId = normalized.sync.id; epoch = normalized.sync.epoch;
|
|
45
|
+
sentTurns.clear();
|
|
46
|
+
for (const turn of normalized.turns) if (!turn.cacheStub) sentTurns.set(turn.id, turn);
|
|
47
|
+
client.traceSync?.('stream-reset-sent', threadId, { seq: normalized.sync.seq,
|
|
48
|
+
turnOrder: normalized.turns.map(turn => turn.id) });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const batch = structuredClone([...pending.values()].map(change => change.turn));
|
|
52
|
+
const thread = { ...record.thread, turns: [] }, sync = { id: syncId, epoch, fromSeq, toSeq };
|
|
53
|
+
const turnOrder = record.thread.turns.map(turn => turn.id), retained = new Set(turnOrder);
|
|
54
|
+
const trace = tracePending;
|
|
55
|
+
pending.clear(); fromSeq = toSeq = undefined; tracePending = false;
|
|
56
|
+
const base = await normalize({ thread, sync });
|
|
23
57
|
const turns = [], turnPatches = [];
|
|
24
|
-
|
|
25
|
-
|
|
58
|
+
const normalizedTurns = [];
|
|
59
|
+
for (const turn of batch) {
|
|
60
|
+
if (turn.cacheStub) continue;
|
|
61
|
+
const normalized = (await normalize({ thread: { ...thread, turns: [turn] } })).turns[0];
|
|
26
62
|
const patch = timelineSync.diffTurn(sentTurns.get(turn.id), normalized);
|
|
27
63
|
if (patch) turnPatches.push(patch); else turns.push(normalized);
|
|
28
|
-
|
|
64
|
+
normalizedTurns.push(normalized);
|
|
29
65
|
}
|
|
30
|
-
|
|
31
|
-
for (const id of sentTurns.keys()) if (!retained.has(id)) sentTurns.delete(id);
|
|
32
|
-
pending.clear();
|
|
33
|
-
fromSeq = toSeq = undefined;
|
|
66
|
+
if (closed) return;
|
|
34
67
|
await emit('update', { ...base, turns, ...(turnPatches.length ? { turnPatches } : {}) });
|
|
68
|
+
for (const turn of normalizedTurns) sentTurns.set(turn.id, turn);
|
|
69
|
+
for (const id of sentTurns.keys()) if (!retained.has(id)) sentTurns.delete(id);
|
|
70
|
+
if (trace || turns.length || (base.sync.fromSeq <= record.recoverAfter && record.recoverAfter <= base.sync.toSeq)) {
|
|
71
|
+
client.traceSync?.('stream-update-sent', threadId, { closed, fromSeq: base.sync?.fromSeq, toSeq: base.sync?.toSeq, turnOrder,
|
|
72
|
+
messages: turns.flatMap(turn => turn.entries.map(entry => [turn.id, entry.id])),
|
|
73
|
+
patches: turnPatches.map(patch => ({ turnId: patch.id, entries: (patch.entries || []).map(entry => entry.id), appended: (patch.appended || []).map(entry => entry.id) })) });
|
|
74
|
+
}
|
|
35
75
|
} catch (error) { emit('bridge-error', { error: error.message }); response.end(); finish(); }
|
|
36
76
|
finally {
|
|
37
77
|
flushing = false;
|
|
38
|
-
|
|
78
|
+
publishHistorySync();
|
|
79
|
+
if (!closed && (pendingReset || pending.size) && !timer) timer = setTimeout(flush, 0);
|
|
39
80
|
}
|
|
40
81
|
};
|
|
41
82
|
const onTurn = ({ thread, turn, sequence, immediate }) => {
|
|
42
83
|
if (thread.id !== threadId) return;
|
|
43
84
|
if (!fromSeq) fromSeq = sequence;
|
|
44
85
|
toSeq = sequence;
|
|
45
|
-
pending.set(turn.id, turn);
|
|
86
|
+
pending.set(turn.id, { turn, sequence, immediate: immediate || pending.get(turn.id)?.immediate });
|
|
87
|
+
if (immediate) tracePending = true;
|
|
46
88
|
if (immediate) flush(); else if (!timer) timer = setTimeout(flush, 50);
|
|
47
89
|
};
|
|
90
|
+
const onReset = snapshot => {
|
|
91
|
+
if (snapshot.thread.id !== threadId) return;
|
|
92
|
+
pendingReset = structuredClone(snapshot);
|
|
93
|
+
pendingReset.sync.reset = true;
|
|
94
|
+
flush();
|
|
95
|
+
};
|
|
48
96
|
const onSettings = id => { if (id === threadId) emit('settings', client.context(id)); };
|
|
49
97
|
const onRequests = id => { if (id === threadId) emit('approvals', { requests: client.serverRequests.list(threadId) }); };
|
|
50
98
|
const onQueue = id => { if (id === threadId) emit('queue-changed', {}); };
|
|
51
|
-
const onNotification = ({ method, params }) => {
|
|
52
|
-
if (!threadListNotificationMethods.has(method)) return;
|
|
53
|
-
emit('thread-list-changed', { method, threadId: params.threadId || params.thread?.id || '' });
|
|
54
|
-
};
|
|
55
99
|
const onDisconnected = () => { emit('bridge-error', { error: 'Codex 内核连接已断开,请重新连接。' }); response.end(); finish(); };
|
|
100
|
+
const onInvalidated = id => { if (id === threadId) emit('history-invalidated', {}); };
|
|
101
|
+
const onHistorySync = id => { if (id === threadId) publishHistorySync(); };
|
|
102
|
+
const onChatError = value => { if (value.threadId === threadId) emit('chat-error', value); };
|
|
56
103
|
response.once('close', () => { closed = true; finish(); });
|
|
57
104
|
client.on('turn', onTurn); client.on('settings', onSettings); client.on('disconnected', onDisconnected);
|
|
105
|
+
client.on('timeline-reset', onReset);
|
|
58
106
|
client.on('queue-changed', onQueue);
|
|
59
107
|
client.on('requests-changed', onRequests);
|
|
60
|
-
client.on('
|
|
108
|
+
client.on('history-invalidated', onInvalidated);
|
|
109
|
+
client.on('history-sync', onHistorySync);
|
|
110
|
+
client.on('chat-error', onChatError);
|
|
111
|
+
publishHistorySync();
|
|
61
112
|
try {
|
|
113
|
+
const started = performance.now();
|
|
114
|
+
client.traceSync?.('stream-open', threadId, { requestedSeq: requestedSync?.after });
|
|
62
115
|
subscription = await client.acquire(threadId, requestedSync);
|
|
63
116
|
syncId = subscription.record.sync.id; epoch = subscription.record.sync.epoch;
|
|
64
117
|
if (!closed) {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
118
|
+
const initial = structuredClone(subscription.record), catchup = subscription.catchup && structuredClone(subscription.catchup);
|
|
119
|
+
if (pendingReset?.sync.seq <= initial.sync.seq) pendingReset = undefined;
|
|
120
|
+
// Only discard notifications certified by the acquired snapshot/catchup.
|
|
121
|
+
for (const [id, change] of pending) if (change.sequence <= initial.sync.seq) pending.delete(id);
|
|
122
|
+
fromSeq = pending.size ? Math.max(fromSeq, initial.sync.seq + 1) : undefined;
|
|
123
|
+
if (!pending.size) toSeq = undefined;
|
|
124
|
+
tracePending = [...pending.values()].some(change => change.immediate);
|
|
125
|
+
const normalized = await normalize(subscription.confirmed ? { ...initial, thread: { ...initial.thread, turns: [] } } : initial);
|
|
126
|
+
const delivered = subscription.snapshot ? normalized : catchup ? await normalize(catchup) : null;
|
|
127
|
+
const sent = Boolean(delivered) && !closed;
|
|
128
|
+
if (sent) await emit('update', delivered);
|
|
129
|
+
// A timestamp-confirmed cache only needs the new subscription boundary,
|
|
130
|
+
// never an empty replacement window or a repeated body snapshot.
|
|
131
|
+
if (subscription.confirmed && !closed) await emit('sync-ready', {
|
|
132
|
+
thread: normalized.thread, sync: normalized.sync,
|
|
133
|
+
});
|
|
134
|
+
client.traceSync?.('stream-initial-sent', threadId, { ms: Math.round(performance.now() - started), closed,
|
|
135
|
+
snapshot: subscription.snapshot, catchup: Boolean(catchup), sent, seq: normalized.sync?.seq,
|
|
136
|
+
deliveredSync: sent ? delivered.sync : undefined,
|
|
137
|
+
messages: sent ? delivered.turns.flatMap(turn => turn.entries.map(entry => [turn.id, entry.id])) : [],
|
|
138
|
+
windowMessages: normalized.turns.flatMap(turn => turn.entries.map(entry => [turn.id, entry.id])) });
|
|
139
|
+
for (const turn of normalized.turns) if (!turn.cacheStub) sentTurns.set(turn.id, turn);
|
|
71
140
|
emit('queue-changed', {});
|
|
72
141
|
onRequests(threadId);
|
|
73
142
|
streamReady = true;
|
|
143
|
+
publishHistorySync();
|
|
144
|
+
if (!closed && typeof client.acquireActivity === 'function') {
|
|
145
|
+
releaseActivity = client.acquireActivity(threadId, value => emit('dots-activity', value));
|
|
146
|
+
}
|
|
74
147
|
flush();
|
|
75
148
|
}
|
|
76
149
|
await done;
|
|
77
150
|
} catch (error) { emit('bridge-error', { error: error.message }); response.end(); }
|
|
78
151
|
finally {
|
|
152
|
+
client.traceSync?.('stream-close', threadId, { ready: streamReady });
|
|
79
153
|
clearTimeout(timer);
|
|
154
|
+
releaseActivity?.();
|
|
80
155
|
client.off('turn', onTurn); client.off('settings', onSettings); client.off('disconnected', onDisconnected);
|
|
156
|
+
client.off('timeline-reset', onReset);
|
|
81
157
|
client.off('queue-changed', onQueue);
|
|
82
158
|
client.off('requests-changed', onRequests);
|
|
83
|
-
client.off('
|
|
159
|
+
client.off('history-invalidated', onInvalidated);
|
|
160
|
+
client.off('history-sync', onHistorySync);
|
|
161
|
+
client.off('chat-error', onChatError);
|
|
84
162
|
if (subscription) await subscription.release();
|
|
85
163
|
}
|
|
86
164
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export async function consumeUsageReset(client, body) {
|
|
2
|
+
if (typeof body?.idempotencyKey !== 'string' || !body.idempotencyKey.trim() || body.idempotencyKey.length > 200
|
|
3
|
+
|| typeof body.creditId !== 'string' || !body.creditId.trim() || body.creditId.length > 200) {
|
|
4
|
+
throw Object.assign(new Error('请选择重置卡并确认使用。'), { statusCode: 400, code: 'usage_reset_not_submitted' });
|
|
5
|
+
}
|
|
6
|
+
let usage;
|
|
7
|
+
try { usage = await client.usage(); }
|
|
8
|
+
catch (error) { throw Object.assign(new Error('用量尚未确认,请恢复连接后重新选择重置卡。', { cause: error }),
|
|
9
|
+
{ statusCode: 503, code: 'usage_reset_not_submitted' }); }
|
|
10
|
+
if ((usage.accountId || null) !== (body.accountId || null)) {
|
|
11
|
+
throw Object.assign(new Error('Codex 账号已变化,请重新打开用量详情。'), { statusCode: 409, code: 'usage_reset_not_submitted' });
|
|
12
|
+
}
|
|
13
|
+
// The core owns eligibility and the idempotent redemption outcome.
|
|
14
|
+
return client.request('account/rateLimitResetCredit/consume', {
|
|
15
|
+
idempotencyKey: body.idempotencyKey, creditId: body.creditId,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { fileRevision, FILE_PART_BYTES, FILE_READ_LIMIT } from './file-transfer-range.mjs';
|
|
6
|
+
import { readRecord, writeRecord } from './service-lifecycle.mjs';
|
|
7
|
+
|
|
8
|
+
const fail = (message, statusCode = 400) => { throw Object.assign(new Error(message), { statusCode }); };
|
|
9
|
+
const pathKey = value => process.platform === 'win32' ? value.toLowerCase() : value;
|
|
10
|
+
const validName = value => typeof value === 'string' && value.length > 0
|
|
11
|
+
&& !/[<>:"/\\|?*\x00-\x1f]/.test(value) && !/[ .]$/.test(value) && !['.', '..'].includes(value)
|
|
12
|
+
&& !/^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i.test(value);
|
|
13
|
+
|
|
14
|
+
// A saved copy belongs to the authorized external asset, rather than its current chat page.
|
|
15
|
+
export class WorkstationDownloads {
|
|
16
|
+
constructor(stateRoot) {
|
|
17
|
+
this.recordPath = path.join(stateRoot, 'external-file-copies.json');
|
|
18
|
+
this.copies = readRecord(this.recordPath) || {};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async copy(sourceId) {
|
|
22
|
+
const record = this.copies[sourceId];
|
|
23
|
+
if (!record) return null;
|
|
24
|
+
try {
|
|
25
|
+
const stat = await fs.stat(record.path);
|
|
26
|
+
return stat.isFile() && fileRevision(stat) === record.revision && stat.size === record.size ? record : null;
|
|
27
|
+
} catch (error) { if (['ENOENT', 'ENOTDIR'].includes(error.code)) return null; throw error; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async folder(rpc, locations, selection) {
|
|
31
|
+
let root;
|
|
32
|
+
if (selection.target === 'downloads') root = locations.downloads;
|
|
33
|
+
else if (selection.target === 'project' && typeof selection.projectId === 'string' && selection.projectId) {
|
|
34
|
+
const { project } = await rpc('project/read', { projectId: selection.projectId });
|
|
35
|
+
const selected = selection.root || project.roots[0]?.path;
|
|
36
|
+
root = project.roots.find(item => pathKey(item.path) === pathKey(selected))?.path;
|
|
37
|
+
}
|
|
38
|
+
if (!root || !path.isAbsolute(root)) fail('请选择电脑项目或下载目录。');
|
|
39
|
+
const relative = selection.path || '';
|
|
40
|
+
if (typeof relative !== 'string' || relative.split('/').some(part => part && !validName(part))) fail('子文件夹路径无效。');
|
|
41
|
+
const realRoot = await fs.realpath(root), target = path.join(realRoot, ...relative.split('/'));
|
|
42
|
+
const realTarget = await fs.realpath(target), difference = path.relative(realRoot, realTarget);
|
|
43
|
+
if (difference === '..' || difference.startsWith('..' + path.sep) || path.isAbsolute(difference)) fail('只能选择当前目录内的子文件夹。');
|
|
44
|
+
if (!(await fs.stat(realTarget)).isDirectory()) fail('所选路径不是文件夹。');
|
|
45
|
+
return { root, path: relative, absolutePath: target, realPath: realTarget,
|
|
46
|
+
parent: relative ? relative.split('/').slice(0, -1).join('/') : null };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async browse(rpc, locations, selection) {
|
|
50
|
+
const folder = await this.folder(rpc, locations, selection);
|
|
51
|
+
const entries = (await fs.readdir(folder.realPath, { withFileTypes: true })).filter(entry => entry.isDirectory())
|
|
52
|
+
.map(entry => ({ name: entry.name, path: [folder.path, entry.name].filter(Boolean).join('/') }))
|
|
53
|
+
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN', { numeric: true }));
|
|
54
|
+
const { realPath, ...visible } = folder;
|
|
55
|
+
return { ...visible, entries };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async save(rpc, locations, selection, asset, readPart, signal) {
|
|
59
|
+
const folder = await this.folder(rpc, locations, selection);
|
|
60
|
+
const partial = path.join(folder.realPath, `.code-relax-${crypto.randomUUID()}.download.part`);
|
|
61
|
+
let handle, result, created = false;
|
|
62
|
+
try {
|
|
63
|
+
signal?.throwIfAborted();
|
|
64
|
+
handle = await fs.open(partial, 'wx', 0o600);
|
|
65
|
+
created = true;
|
|
66
|
+
let offset = 0, size, revision, name, mimeType;
|
|
67
|
+
do {
|
|
68
|
+
signal?.throwIfAborted();
|
|
69
|
+
const query = new URLSearchParams({ offset: String(offset), length: String(FILE_PART_BYTES) });
|
|
70
|
+
if (revision) query.set('revision', revision);
|
|
71
|
+
const part = await readPart(asset, query);
|
|
72
|
+
signal?.throwIfAborted();
|
|
73
|
+
if (offset === 0) {
|
|
74
|
+
size = part.size; revision = part.revision; name = part.name || asset.name; mimeType = part.mimeType || asset.mimeType;
|
|
75
|
+
if (!validName(name)) fail('文件名无效,请修正来源文件的名称。');
|
|
76
|
+
if (!Number.isSafeInteger(size) || size < 1 || size > FILE_READ_LIMIT || !revision) fail('文件大小无效或超过 20 MiB 上限。');
|
|
77
|
+
}
|
|
78
|
+
if (part.size !== size || part.revision !== revision || part.offset !== offset
|
|
79
|
+
|| !part.bytes?.length || part.bytes.length > Math.min(FILE_PART_BYTES, size - offset)) fail('文件内容发生变化或分片不完整。', 409);
|
|
80
|
+
let written = 0;
|
|
81
|
+
while (written < part.bytes.length) {
|
|
82
|
+
const { bytesWritten } = await handle.write(part.bytes, written, part.bytes.length - written, offset + written);
|
|
83
|
+
if (!bytesWritten) fail('电脑文件写入不完整。', 502);
|
|
84
|
+
written += bytesWritten;
|
|
85
|
+
}
|
|
86
|
+
offset += part.bytes.length;
|
|
87
|
+
} while (offset < size);
|
|
88
|
+
await handle.close(); handle = null;
|
|
89
|
+
signal?.throwIfAborted();
|
|
90
|
+
// Recheck the destination after the remote read, before creating the final copy.
|
|
91
|
+
const current = await this.folder(rpc, locations, selection);
|
|
92
|
+
if (pathKey(current.realPath) !== pathKey(folder.realPath)) fail('目标目录发生变化,请重新选择。', 409);
|
|
93
|
+
const extension = path.extname(name), body = name.slice(0, name.length - extension.length);
|
|
94
|
+
for (let number = 0; ; number++) {
|
|
95
|
+
const filename = number ? `${body} (${number})${extension}` : name;
|
|
96
|
+
const destination = path.join(folder.realPath, filename);
|
|
97
|
+
try {
|
|
98
|
+
await fs.copyFile(partial, destination, constants.COPYFILE_EXCL);
|
|
99
|
+
const stat = await fs.stat(destination);
|
|
100
|
+
if (stat.size !== size) { await fs.unlink(destination); fail('电脑副本大小核对失败。', 502); }
|
|
101
|
+
result = { path: destination, name: filename, size, revision: fileRevision(stat), mimeType };
|
|
102
|
+
break;
|
|
103
|
+
} catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
104
|
+
}
|
|
105
|
+
this.copies[asset.id] = result;
|
|
106
|
+
try { writeRecord(this.recordPath, this.copies); }
|
|
107
|
+
catch (error) { result.warning = '文件已保存,副本记录未能持久保存;重启后请从原文件重新选择。'; console.error('电脑副本记录保存失败', error); }
|
|
108
|
+
return result;
|
|
109
|
+
} finally {
|
|
110
|
+
await handle?.close();
|
|
111
|
+
if (created) {
|
|
112
|
+
try { await fs.rm(partial, { force: true }); }
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (!result) throw error;
|
|
115
|
+
result.warning = '文件已保存,下载临时文件未能清理。'; console.error('电脑下载临时文件清理失败', error);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|