@walkhi/code-relax 0.1.0-beta.76 → 0.1.0-beta.78

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.
@@ -118,9 +118,10 @@ export class SharedAppServer extends EventEmitter {
118
118
  await new Promise((resolve, reject) => { socket.once('open', resolve); socket.once('error', reject); });
119
119
  try {
120
120
  const initialized = await this.request('initialize', { clientInfo: { name: 'codex_remote_shared', version: '1.0.0' }, capabilities: { experimentalApi: true } });
121
- // The first user-agent product reports the running server build, not the
122
- // selected executable that may only take effect after a future restart.
123
- this.serverVersion = String(initialized?.userAgent || '').match(/^[^\s/]+\/([^\s]+)/)?.[1] || '';
121
+ // The first user-agent product reports the running server build. Its name
122
+ // may contain spaces ("Codex Desktop"); the slash delimits the version.
123
+ // Do not substitute the selected executable's pending version.
124
+ this.serverVersion = String(initialized?.userAgent || '').match(/^[^/]+\/([^\s]+)/)?.[1] || '';
124
125
  socket.send(JSON.stringify({ method: 'initialized' }));
125
126
  this.ready = true;
126
127
  this.usageCache.read(() => this.request('account/rateLimits/read')).catch(error => this.emit('usage-error', error));
@@ -1,120 +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
- }
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 } 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 || !revision) fail('文件大小无效。');
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walkhi/code-relax",
3
- "version": "0.1.0-beta.76",
3
+ "version": "0.1.0-beta.78",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -41,8 +41,8 @@
41
41
  "tag": "beta"
42
42
  },
43
43
  "optionalDependencies": {
44
- "@walkhi/code-relax-native-win32-x64": "0.1.0-beta.76",
45
- "@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.76",
46
- "@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.76"
44
+ "@walkhi/code-relax-native-win32-x64": "0.1.0-beta.78",
45
+ "@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.78",
46
+ "@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.78"
47
47
  }
48
48
  }