dsh-sql 0.4.5 → 0.5.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/CHANGELOG.md +14 -0
- package/README.en.md +8 -0
- package/README.md +8 -0
- package/docs/USAGE.md +5 -4
- package/lib/adapters.d.ts +16 -2
- package/lib/adapters.js +63 -28
- package/lib/index.js +6 -2
- package/lib/tools.d.ts +17 -2
- package/lib/tools.js +141 -17
- package/lib/update-client.js +408 -0
- package/package.json +16 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
[返回简介](README.md) · [使用说明](docs/USAGE.md) · [验证记录](docs/VALIDATION.md)
|
|
4
4
|
|
|
5
|
+
## 0.5.0 (2026-10-26)
|
|
6
|
+
|
|
7
|
+
- Add this plugin’s own update controls and independent preferences. Automatic installation stays off until explicitly enabled.
|
|
8
|
+
- Confirm manual installs, preserve local sources and disabled state, pause for tasks or unsaved changes, and use native DSH recovery without forced restarts.
|
|
9
|
+
- Require the public DSH 0.2.1-rc.3 update APIs; Suite components remain managed through Suite.
|
|
10
|
+
|
|
11
|
+
## 0.4.6 (2026-10-20)
|
|
12
|
+
|
|
13
|
+
- 修复 sql_query 只读校验与 PostgreSQL 反斜杠语义不一致的问题:词法去噪改为按引擎处理反斜杠(MySQL 默认转义 / PostgreSQL 只有 `E'…'`、`U&'…'` 转义 / SQLite 与引号标识符里都不转义)。`SELECT '\'; DELETE FROM users; --'` 这类双语句载荷会被拒绝,不再能在 readOnly=true 下删表
|
|
14
|
+
- 修复 PRAGMA 函数式写法绕过只读校验的问题:`PRAGMA foreign_keys(0)`、`writable_schema(1)`、`user_version(4242)` 等与 `name=value` 等价,现在改为只读 PRAGMA 白名单,只放行 table_info / index_list / page_count 等内省语句
|
|
15
|
+
- readOnly=true 时把只读约束下沉到数据库自身:SQLite 以只读方式打开文件、PostgreSQL 加 `-c default_transaction_read_only=on`、MySQL 每个新连接执行 `SET SESSION TRANSACTION READ ONLY`。词法校验被绕过时服务器仍会拒绝写入
|
|
16
|
+
- sql_exec 审批文本不再截断到 200 字符:逐条列出每条语句并附语句类型统计;超过 800 字符时把完整 SQL 指回工具参数,200 字符之后的 DROP TABLE 不会再从审批卡里消失
|
|
17
|
+
- 新增 `test/readonly-hardening.test.mjs`(10 项回归),`tools.test.mjs` 的 PRAGMA 断言同步更新
|
|
18
|
+
|
|
5
19
|
## 0.4.5 (2026-10-05)
|
|
6
20
|
|
|
7
21
|
- 保留 SQLite 64 位整数与三种数据库的同名列;零行结果保留列名。同名列 JSON 导出使用 columns / rows,避免后列覆盖前列。
|
package/README.en.md
CHANGED
|
@@ -8,6 +8,8 @@ Connect SQLite, MySQL or PostgreSQL to query data and inspect schemas.
|
|
|
8
8
|
|
|
9
9
|
[](https://www.npmjs.com/package/dsh-sql) [](https://www.npmjs.com/package/dsh-sql)
|
|
10
10
|
|
|
11
|
+
Feedback and contributions are welcome: report [issues](https://github.com/STARDUSTLC666/dsh-sql/issues) or submit [pull requests](https://github.com/STARDUSTLC666/dsh-sql/pulls).
|
|
12
|
+
|
|
11
13
|
## What it does
|
|
12
14
|
|
|
13
15
|
- Manage connections and inspect schemas or database summaries.
|
|
@@ -41,6 +43,12 @@ Detailed configuration, tool arguments and troubleshooting are in the [usage gui
|
|
|
41
43
|
- [Validation scope and history](docs/VALIDATION.md)
|
|
42
44
|
- [Report a problem or suggest a feature](https://github.com/STARDUSTLC666/dsh-sql/issues)
|
|
43
45
|
|
|
46
|
+
## Plugin updates
|
|
47
|
+
|
|
48
|
+
Requires DSH 0.2.1-rc.3 or newer. Each plugin provides its own check, release-note and confirmed-install controls on its page or in settings. Preferences are independent. Automatic checks are on by default; **automatic installation is off by default**. Explicitly enabled automatic updates only install compatible stable patches while idle with no unsaved changes. Disable, defer or ignore reminders per plugin. DSH is never restarted or upgraded automatically, and local-source installs are preserved. Suite components clearly direct updates through Suite.
|
|
49
|
+
|
|
50
|
+
Older releases need one manual update in native plugin management to acquire these controls. Publishing to npm does not replace an existing user installation.
|
|
51
|
+
|
|
44
52
|
## License
|
|
45
53
|
|
|
46
54
|
[MIT](LICENSE)
|
package/README.md
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
[](https://www.npmjs.com/package/dsh-sql) [](https://www.npmjs.com/package/dsh-sql)
|
|
10
10
|
|
|
11
|
+
欢迎使用,遇到问题或有改进建议,请提交 [issues](https://github.com/STARDUSTLC666/dsh-sql/issues) 和 [PR](https://github.com/STARDUSTLC666/dsh-sql/pulls)。
|
|
12
|
+
|
|
11
13
|
## 功能
|
|
12
14
|
|
|
13
15
|
- 管理连接,查询结构和数据概览。
|
|
@@ -41,6 +43,12 @@ SQLite 需可访问的数据库文件;MySQL / PostgreSQL 需对应服务与登
|
|
|
41
43
|
- [验证范围与历史记录](docs/VALIDATION.md)
|
|
42
44
|
- [问题反馈与功能建议](https://github.com/STARDUSTLC666/dsh-sql/issues)
|
|
43
45
|
|
|
46
|
+
## 插件更新
|
|
47
|
+
|
|
48
|
+
需要 DSH 0.2.1-rc.3 或更新版本。每个插件在自己的页面或设置项提供检查、更新记录与确认安装入口,开关独立保存。自动检查默认开启;**自动安装默认关闭**,只有明确授权后,兼容的稳定补丁才会在空闲且没有未保存修改时安装。可单独关闭、推迟或忽略提醒;不会强制重启 DSH、升级 DSH 本体或替换本地源码安装。Suite 安装的组件会明确提示通过 Suite 整包更新。
|
|
49
|
+
|
|
50
|
+
旧版用户先在官方插件管理中手动更新一次,之后才会获得此入口。发布到 npm 不会直接替换用户已经安装的版本。
|
|
51
|
+
|
|
44
52
|
## License
|
|
45
53
|
|
|
46
54
|
[MIT](LICENSE)
|
package/docs/USAGE.md
CHANGED
|
@@ -44,7 +44,7 @@ dsh plugin --profile web remove dsh-sql
|
|
|
44
44
|
maxRows: 1000 # 查询返回行数上限(1-10000)
|
|
45
45
|
queryTimeoutMs: 60000 # 单次查询超时(默认 60 秒,5 秒 - 10 分钟)
|
|
46
46
|
execTimeoutMs: 120000 # 单次写操作超时(默认 120 秒,5 秒 - 10 分钟)
|
|
47
|
-
readOnly: false # true 时禁用 sql_exec
|
|
47
|
+
readOnly: false # true 时禁用 sql_exec,并让数据库自身以只读方式连接
|
|
48
48
|
writeApproval: true # 写操作先弹审批(默认 true)
|
|
49
49
|
```
|
|
50
50
|
|
|
@@ -55,7 +55,7 @@ dsh plugin --profile web remove dsh-sql
|
|
|
55
55
|
| 工具 | 作用 | 安全 |
|
|
56
56
|
| :-- | :-- | :-- |
|
|
57
57
|
| `sql_list` | 列出连接 + 连通性测试 | — |
|
|
58
|
-
| `sql_query` | 只读查询(SELECT/PRAGMA/EXPLAIN/SHOW/DESCRIBE/WITH)|
|
|
58
|
+
| `sql_query` | 只读查询(SELECT/PRAGMA/EXPLAIN/SHOW/DESCRIBE/WITH)| 按引擎去噪的关键字白名单 + 拒绝多语句 + PRAGMA 白名单 |
|
|
59
59
|
| `sql_exec` | 写操作/DDL(可多语句脚本)| readOnly 禁用 + 审批门 |
|
|
60
60
|
| `sql_schema` | 表清单 / 表结构 | 标识符白名单校验 |
|
|
61
61
|
| `sql_stats` | 表数量、行数与库体积概览 | 表名引用 + 查询失败隔离 |
|
|
@@ -75,9 +75,10 @@ sql_exec { sql: UPDATE orders SET status = 'paid' WHERE id = 42 }
|
|
|
75
75
|
|
|
76
76
|
## 安全设计
|
|
77
77
|
|
|
78
|
-
- **词法级只读保护**:sql_query 先剥离字符串/注释再校验,拒绝 data-modifying CTE(WITH…DELETE/UPDATE)、SELECT INTO、FOR UPDATE/FOR SHARE
|
|
78
|
+
- **词法级只读保护**:sql_query 先剥离字符串/注释再校验,拒绝 data-modifying CTE(WITH…DELETE/UPDATE)、SELECT INTO、FOR UPDATE/FOR SHARE、多语句,以及不在只读白名单里的 PRAGMA。去噪按引擎处理反斜杠(MySQL 默认转义、PostgreSQL 只有 `E'…'`/`U&'…'` 转义、SQLite 与引号标识符里都不转义),避免「校验看到一条、服务器执行两条」
|
|
79
79
|
- **写审批门**:sql_exec 默认弹审批(对齐 dsh-email 的发信审批),headless 环境无审批通道时拒绝执行
|
|
80
|
-
- **readOnly
|
|
80
|
+
- **readOnly 模式**:生产库可整体禁用写。除了禁用 sql_exec,还会把只读约束下沉到数据库自身:SQLite 以只读方式打开文件,PostgreSQL 用 `-c default_transaction_read_only=on` 启动参数,MySQL 在每个新连接上执行 `SET SESSION TRANSACTION READ ONLY`(需要 MySQL 5.6.5+ / MariaDB 10.0+)。即使词法校验被绕过,服务器也会拒绝写入
|
|
81
|
+
- **审批卡不藏语句**:sql_exec 的审批文本逐条列出每条语句,并给出一行语句类型统计;超过 800 字符时把完整 SQL 指回工具参数,第二条 DROP / DELETE 不会因为截断而从审批卡里消失
|
|
81
82
|
- **流式行数钳制**:SQLite 迭代器 / MySQL Readable / PostgreSQL Query 行事件最多收集 maxRows+1 行,超量标记 truncated;MySQL 和 PostgreSQL 在达到上限时关闭该查询的专用连接,未达上限则正常归还连接池,避免全量结果驻留内存
|
|
82
83
|
- **可取消执行**:查询与写操作遵守 Harness 的 `exec.signal`;取消时会中止等待并销毁正在工作的 MySQL/PostgreSQL 专用连接
|
|
83
84
|
- **大整数无损**:数据库返回的 bigint 在 JavaScript 安全整数范围内输出 number,超出范围则输出十进制字符串,避免静默丢精度
|
package/lib/adapters.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import mysql from 'mysql2/promise';
|
|
2
|
+
import pg from 'pg';
|
|
1
3
|
import { type SqlConnectionConfig } from './config.js';
|
|
2
4
|
/** 查询结果:列名 + 行(值数组,无损 JSON 友好)。 */
|
|
3
5
|
export interface QueryResult {
|
|
@@ -21,5 +23,17 @@ export interface DatabaseAdapter {
|
|
|
21
23
|
ping(signal?: AbortSignal): Promise<void>;
|
|
22
24
|
close(): Promise<void>;
|
|
23
25
|
}
|
|
24
|
-
/**
|
|
25
|
-
export
|
|
26
|
+
/** 适配器额外选项:readOnly 时把只读约束下沉到数据库自身。 */
|
|
27
|
+
export interface AdapterOptions {
|
|
28
|
+
readOnly?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/** MySQL 连接池配置(导出以便测试断言,不含连接)。 */
|
|
31
|
+
export declare function mysqlPoolConfig(connection: SqlConnectionConfig): mysql.PoolOptions;
|
|
32
|
+
/**
|
|
33
|
+
* PostgreSQL 连接池配置。readOnly 时用启动参数把会话默认事务设为只读:
|
|
34
|
+
* 即使词法校验被绕过(多语句、反斜杠、注释技巧),服务器也会以
|
|
35
|
+
* "cannot execute ... in a read-only transaction" 拒绝写操作。
|
|
36
|
+
*/
|
|
37
|
+
export declare function postgresPoolConfig(connection: SqlConnectionConfig, readOnly?: boolean): pg.PoolConfig;
|
|
38
|
+
/** 按连接配置创建适配器;readOnly 会下沉到引擎自身的只读约束。 */
|
|
39
|
+
export declare function createAdapter(connection: SqlConnectionConfig, options?: AdapterOptions): DatabaseAdapter;
|
package/lib/adapters.js
CHANGED
|
@@ -117,12 +117,56 @@ function streamPostgresQuery(client, sql, limit, discard) {
|
|
|
117
117
|
client.query(query);
|
|
118
118
|
});
|
|
119
119
|
}
|
|
120
|
+
/** MySQL 连接池配置(导出以便测试断言,不含连接)。 */
|
|
121
|
+
export function mysqlPoolConfig(connection) {
|
|
122
|
+
return {
|
|
123
|
+
host: connection.host ?? 'localhost',
|
|
124
|
+
port: connection.port ?? 3306,
|
|
125
|
+
user: connection.user ?? '',
|
|
126
|
+
password: connection.password ?? '',
|
|
127
|
+
database: connection.database ?? '',
|
|
128
|
+
connectionLimit: 5,
|
|
129
|
+
enableKeepAlive: true,
|
|
130
|
+
supportBigNumbers: true,
|
|
131
|
+
bigNumberStrings: true,
|
|
132
|
+
// 显式关掉多语句:即使词法校验被绕过,服务器也不会把第二句当命令执行。
|
|
133
|
+
multipleStatements: false,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* PostgreSQL 连接池配置。readOnly 时用启动参数把会话默认事务设为只读:
|
|
138
|
+
* 即使词法校验被绕过(多语句、反斜杠、注释技巧),服务器也会以
|
|
139
|
+
* "cannot execute ... in a read-only transaction" 拒绝写操作。
|
|
140
|
+
*/
|
|
141
|
+
export function postgresPoolConfig(connection, readOnly = false) {
|
|
142
|
+
const config = {
|
|
143
|
+
host: connection.host ?? 'localhost',
|
|
144
|
+
port: connection.port ?? 5432,
|
|
145
|
+
user: connection.user ?? '',
|
|
146
|
+
password: connection.password ?? '',
|
|
147
|
+
database: connection.database ?? '',
|
|
148
|
+
max: 5,
|
|
149
|
+
};
|
|
150
|
+
if (readOnly)
|
|
151
|
+
config.options = '-c default_transaction_read_only=on';
|
|
152
|
+
return config;
|
|
153
|
+
}
|
|
120
154
|
/** SQLite 适配器(node:sqlite,零依赖)。 */
|
|
121
155
|
class SqliteAdapter {
|
|
122
156
|
engine = 'sqlite';
|
|
123
157
|
db;
|
|
124
|
-
constructor(file) {
|
|
125
|
-
|
|
158
|
+
constructor(file, readOnly = false) {
|
|
159
|
+
const memory = file === ':memory:';
|
|
160
|
+
// 内存库没有落盘风险,也不支持只读打开;文件库在 readOnly 下由 SQLite
|
|
161
|
+
// 自己拒绝任何写入(PRAGMA 写、DDL、DML 都会报 attempt to write a readonly database)。
|
|
162
|
+
const options = readOnly && !memory ? { readOnly: true } : {};
|
|
163
|
+
try {
|
|
164
|
+
this.db = new DatabaseSync(memory ? ':memory:' : file, options);
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
168
|
+
throw new Error('无法打开 SQLite 文件 ' + file + '(readOnly=' + readOnly + '):' + detail);
|
|
169
|
+
}
|
|
126
170
|
this.db.exec('PRAGMA busy_timeout = 5000');
|
|
127
171
|
}
|
|
128
172
|
async listTables(signal) {
|
|
@@ -188,18 +232,15 @@ class SqliteAdapter {
|
|
|
188
232
|
class MysqlAdapter {
|
|
189
233
|
engine = 'mysql';
|
|
190
234
|
pool;
|
|
191
|
-
constructor(connection) {
|
|
192
|
-
this.pool = mysql.createPool(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
supportBigNumbers: true,
|
|
201
|
-
bigNumberStrings: true,
|
|
202
|
-
});
|
|
235
|
+
constructor(connection, readOnly = false) {
|
|
236
|
+
this.pool = mysql.createPool(mysqlPoolConfig(connection));
|
|
237
|
+
if (readOnly) {
|
|
238
|
+
// 会话级只读:MySQL 5.6.5+ 的 SET SESSION TRANSACTION READ ONLY 会让
|
|
239
|
+
// 该连接上后续任何 DML/DDL 被服务器拒绝,不依赖词法校验。
|
|
240
|
+
this.pool.on('connection', (session) => {
|
|
241
|
+
void session.query('SET SESSION TRANSACTION READ ONLY').catch(() => { });
|
|
242
|
+
});
|
|
243
|
+
}
|
|
203
244
|
}
|
|
204
245
|
async withSignalConnection(signal, work) {
|
|
205
246
|
signal?.throwIfAborted();
|
|
@@ -274,15 +315,8 @@ class MysqlAdapter {
|
|
|
274
315
|
class PostgresAdapter {
|
|
275
316
|
engine = 'postgres';
|
|
276
317
|
pool;
|
|
277
|
-
constructor(connection) {
|
|
278
|
-
this.pool = new pg.Pool(
|
|
279
|
-
host: connection.host ?? 'localhost',
|
|
280
|
-
port: connection.port ?? 5432,
|
|
281
|
-
user: connection.user ?? '',
|
|
282
|
-
password: connection.password ?? '',
|
|
283
|
-
database: connection.database ?? '',
|
|
284
|
-
max: 5,
|
|
285
|
-
});
|
|
318
|
+
constructor(connection, readOnly = false) {
|
|
319
|
+
this.pool = new pg.Pool(postgresPoolConfig(connection, readOnly));
|
|
286
320
|
}
|
|
287
321
|
async withSignalClient(signal, work) {
|
|
288
322
|
signal?.throwIfAborted();
|
|
@@ -371,11 +405,12 @@ class PostgresAdapter {
|
|
|
371
405
|
await this.pool.end();
|
|
372
406
|
}
|
|
373
407
|
}
|
|
374
|
-
/**
|
|
375
|
-
export function createAdapter(connection) {
|
|
408
|
+
/** 按连接配置创建适配器;readOnly 会下沉到引擎自身的只读约束。 */
|
|
409
|
+
export function createAdapter(connection, options = {}) {
|
|
410
|
+
const readOnly = options.readOnly === true;
|
|
376
411
|
if (connection.engine === 'sqlite')
|
|
377
|
-
return new SqliteAdapter(connection.file ?? ':memory:');
|
|
412
|
+
return new SqliteAdapter(connection.file ?? ':memory:', readOnly);
|
|
378
413
|
if (connection.engine === 'mysql')
|
|
379
|
-
return new MysqlAdapter(connection);
|
|
380
|
-
return new PostgresAdapter(connection);
|
|
414
|
+
return new MysqlAdapter(connection, readOnly);
|
|
415
|
+
return new PostgresAdapter(connection, readOnly);
|
|
381
416
|
}
|
package/lib/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { registerUpdates } from '@stardustlc/dsh-updates';
|
|
1
2
|
/**
|
|
2
3
|
* dsh-sql —— 工程师级数据库工具插件(node 半身,配置走 cordis.patch.yml)。
|
|
3
4
|
*
|
|
@@ -9,7 +10,7 @@
|
|
|
9
10
|
* @module dsh-sql
|
|
10
11
|
*/
|
|
11
12
|
import { resolveConfig } from './config.js';
|
|
12
|
-
import { buildSqlTools } from './tools.js';
|
|
13
|
+
import { buildSqlTools, summarizeWriteScript } from './tools.js';
|
|
13
14
|
/** cordis 服务注入:apply 里要用 ctx.tools,必须显式声明。 */
|
|
14
15
|
export const name = 'sql';
|
|
15
16
|
export const inject = ['tools'];
|
|
@@ -17,6 +18,7 @@ export const inject = ['tools'];
|
|
|
17
18
|
* 插件入口:解析配置、构建六工具、给 sql_exec 注入审批门。
|
|
18
19
|
*/
|
|
19
20
|
export function apply(ctx, config) {
|
|
21
|
+
registerUpdates(ctx, { name: "dsh-sql" });
|
|
20
22
|
const cfg = resolveConfig(config);
|
|
21
23
|
const { tools, adapters } = buildSqlTools(cfg);
|
|
22
24
|
if (cfg.writeApproval) {
|
|
@@ -27,7 +29,9 @@ export function apply(ctx, config) {
|
|
|
27
29
|
const sql = typeof args.sql === 'string' ? args.sql : '';
|
|
28
30
|
return {
|
|
29
31
|
kind: 'ask',
|
|
30
|
-
|
|
32
|
+
// 不截断整段 SQL:先逐条列出每条语句与语句类型统计,再附完整 SQL。
|
|
33
|
+
// 只截 200 字符会把第二条 DROP / DELETE 挡在审批卡之外。
|
|
34
|
+
reason: 'sql_exec 写操作需要确认:\n' + summarizeWriteScript(sql),
|
|
31
35
|
};
|
|
32
36
|
});
|
|
33
37
|
}
|
package/lib/tools.d.ts
CHANGED
|
@@ -26,8 +26,22 @@ export interface SqlToolDefinition {
|
|
|
26
26
|
execute(args: unknown, exec: unknown): Promise<unknown>;
|
|
27
27
|
timeoutMs?: number;
|
|
28
28
|
}
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/** 词法校验需要区分的三种引擎。 */
|
|
30
|
+
type SqlEngine = 'sqlite' | 'mysql' | 'postgres';
|
|
31
|
+
/**
|
|
32
|
+
* 校验只读查询:词法去噪后白名单开头 + 写关键字扫描 + 单语句 + PRAGMA 白名单。
|
|
33
|
+
*
|
|
34
|
+
* `engine` 决定反斜杠转义规则(见 stripSqlNoise);不传时按最保守的方式把
|
|
35
|
+
* 反斜杠当字面量,宁可误拒也不放过第二条语句。
|
|
36
|
+
*/
|
|
37
|
+
export declare function assertReadQuery(sql: string, engine?: SqlEngine): string;
|
|
38
|
+
/**
|
|
39
|
+
* 把一段写操作脚本压成审批卡可读的摘要。
|
|
40
|
+
*
|
|
41
|
+
* 关键点:逐条列出每条语句,并额外给一行语句类型统计。即使脚本体被省略,
|
|
42
|
+
* 第二条、第 N 条语句的 DROP / DELETE 也不会从审批文本里消失。
|
|
43
|
+
*/
|
|
44
|
+
export declare function summarizeWriteScript(sql: string): string;
|
|
31
45
|
/** 查询结果转 CSV 文本(RFC 4180 风格转义)。 */
|
|
32
46
|
export declare function toCsv(columns: string[], rows: unknown[][]): string;
|
|
33
47
|
/** 审批执行上下文的最小面。 */
|
|
@@ -42,3 +56,4 @@ export declare function buildSqlTools(config: ResolvedSqlConfig): {
|
|
|
42
56
|
tools: SqlToolDefinition[];
|
|
43
57
|
adapters: Map<string, DatabaseAdapter>;
|
|
44
58
|
};
|
|
59
|
+
export {};
|
package/lib/tools.js
CHANGED
|
@@ -40,8 +40,30 @@ function executionSignal(exec) {
|
|
|
40
40
|
}
|
|
41
41
|
/** 只读语句关键字白名单。 */
|
|
42
42
|
const READ_KEYWORDS = /^(select|pragma|explain|show|describe|desc|with)\b/i;
|
|
43
|
-
/**
|
|
44
|
-
|
|
43
|
+
/**
|
|
44
|
+
* PostgreSQL 的 E'…' / U&'…' 会把反斜杠当转义;普通 '…' 在
|
|
45
|
+
* standard_conforming_strings=on(9.1 起的默认值)下反斜杠是普通字符。
|
|
46
|
+
*/
|
|
47
|
+
function isPostgresEscapeString(sql, quoteIndex) {
|
|
48
|
+
const prev = sql[quoteIndex - 1];
|
|
49
|
+
const isWordChar = (ch) => ch !== undefined && /[A-Za-z0-9_$]/.test(ch);
|
|
50
|
+
if (prev === 'E' || prev === 'e')
|
|
51
|
+
return !isWordChar(sql[quoteIndex - 2]);
|
|
52
|
+
if (prev === '&') {
|
|
53
|
+
const u = sql[quoteIndex - 2];
|
|
54
|
+
return (u === 'U' || u === 'u') && !isWordChar(sql[quoteIndex - 3]);
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 去掉字符串、引号标识符与注释,保留真实 SQL 关键字与分号。
|
|
60
|
+
*
|
|
61
|
+
* 反斜杠规则按引擎对齐,否则去噪结果会和服务器看到的分割方式不一致:
|
|
62
|
+
* MySQL 默认把 \ 当转义;PostgreSQL 只有 E'…' / U&'…' 才转义;
|
|
63
|
+
* SQLite 字符串里根本没有反斜杠转义。引号标识符("…" 与 `…`)在三种
|
|
64
|
+
* 引擎里都不把反斜杠当转义,永远按字面量处理。
|
|
65
|
+
*/
|
|
66
|
+
function stripSqlNoise(sql, engine) {
|
|
45
67
|
let out = '';
|
|
46
68
|
let i = 0;
|
|
47
69
|
while (i < sql.length) {
|
|
@@ -74,6 +96,7 @@ function stripSqlNoise(sql) {
|
|
|
74
96
|
if (ch === "'") {
|
|
75
97
|
out += ' ';
|
|
76
98
|
i += 1;
|
|
99
|
+
const backslashEscapes = engine === 'mysql' || (engine === 'postgres' && isPostgresEscapeString(sql, i - 1));
|
|
77
100
|
while (i < sql.length) {
|
|
78
101
|
if (sql[i] === "'" && sql[i + 1] === "'") {
|
|
79
102
|
i += 2;
|
|
@@ -83,7 +106,7 @@ function stripSqlNoise(sql) {
|
|
|
83
106
|
i += 1;
|
|
84
107
|
break;
|
|
85
108
|
}
|
|
86
|
-
if (sql[i] === '\\') {
|
|
109
|
+
if (backslashEscapes && sql[i] === '\\') {
|
|
87
110
|
i += 2;
|
|
88
111
|
continue;
|
|
89
112
|
}
|
|
@@ -99,10 +122,6 @@ function stripSqlNoise(sql) {
|
|
|
99
122
|
i += 1;
|
|
100
123
|
break;
|
|
101
124
|
}
|
|
102
|
-
if (sql[i] === '\\') {
|
|
103
|
-
i += 2;
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
125
|
i += 1;
|
|
107
126
|
}
|
|
108
127
|
continue;
|
|
@@ -132,10 +151,74 @@ function stripSqlNoise(sql) {
|
|
|
132
151
|
}
|
|
133
152
|
/** 写操作关键字:出在 SELECT/EXPLAIN/WITH 语句里即拒绝。 */
|
|
134
153
|
const WRITE_KEYWORDS = /\b(insert|update|delete|replace|merge|drop|alter|create|truncate|call|execute|copy|grant|revoke|attach|detach|vacuum|reindex|refresh|set|reset|begin|commit|rollback|savepoint|release|analyze|load_extension)\b/i;
|
|
135
|
-
/**
|
|
136
|
-
|
|
154
|
+
/** 允许不带参数的只读 PRAGMA(含查询型与会话状态查询)。 */
|
|
155
|
+
const READONLY_PRAGMAS = new Set([
|
|
156
|
+
'application_id',
|
|
157
|
+
'auto_vacuum',
|
|
158
|
+
'cell_size_check',
|
|
159
|
+
'collation_list',
|
|
160
|
+
'compile_options',
|
|
161
|
+
'data_version',
|
|
162
|
+
'database_list',
|
|
163
|
+
'encoding',
|
|
164
|
+
'freelist_count',
|
|
165
|
+
'function_list',
|
|
166
|
+
'journal_mode',
|
|
167
|
+
'legacy_file_format',
|
|
168
|
+
'mmap_size',
|
|
169
|
+
'module_list',
|
|
170
|
+
'page_count',
|
|
171
|
+
'page_size',
|
|
172
|
+
'pragma_list',
|
|
173
|
+
'schema_version',
|
|
174
|
+
'sqlite_version',
|
|
175
|
+
'user_version',
|
|
176
|
+
]);
|
|
177
|
+
/** 允许带一个标识符参数的只读内省 PRAGMA。 */
|
|
178
|
+
const READONLY_PRAGMAS_WITH_ARG = new Set([
|
|
179
|
+
'foreign_key_check',
|
|
180
|
+
'foreign_key_list',
|
|
181
|
+
'index_info',
|
|
182
|
+
'index_list',
|
|
183
|
+
'index_xinfo',
|
|
184
|
+
'integrity_check',
|
|
185
|
+
'quick_check',
|
|
186
|
+
'table_info',
|
|
187
|
+
'table_list',
|
|
188
|
+
'table_xinfo',
|
|
189
|
+
]);
|
|
190
|
+
/**
|
|
191
|
+
* PRAGMA 白名单:只放行只读内省。
|
|
192
|
+
*
|
|
193
|
+
* SQLite 里 `PRAGMA name(value)` 与 `PRAGMA name = value` 完全等价,
|
|
194
|
+
* 只拦改号会让 `PRAGMA foreign_keys(0)` / `PRAGMA writable_schema(1)`
|
|
195
|
+
* 这类会改连接或库文件状态的调用漏过去,所以按名字白名单放行。
|
|
196
|
+
*/
|
|
197
|
+
function assertReadOnlyPragma(statement) {
|
|
198
|
+
const match = /^pragma\s+(?:[a-z_][a-z0-9_]*\.)?([a-z_][a-z0-9_]*)\s*(.*)$/i.exec(statement);
|
|
199
|
+
if (match === null)
|
|
200
|
+
throw new Error('无法解析这条 PRAGMA,sql_query 只接受只读的 PRAGMA 内省语句。');
|
|
201
|
+
const pragmaName = match[1].toLowerCase();
|
|
202
|
+
const rest = match[2].trim();
|
|
203
|
+
if (rest !== '' && /^[=(]/.test(rest)) {
|
|
204
|
+
if (!READONLY_PRAGMAS_WITH_ARG.has(pragmaName)) {
|
|
205
|
+
throw new Error('sql_query 不接受带参数的 PRAGMA ' + pragmaName.toUpperCase() + '(PRAGMA name(value) 与 name=value 等价,会改连接或数据库状态)。请用 sql_exec。');
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (!READONLY_PRAGMAS.has(pragmaName) && !READONLY_PRAGMAS_WITH_ARG.has(pragmaName)) {
|
|
210
|
+
throw new Error('sql_query 只放行只读 PRAGMA 白名单(table_info / index_list / page_count / user_version 等),' + pragmaName.toUpperCase() + ' 不在白名单内。需要修改请用 sql_exec。');
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* 校验只读查询:词法去噪后白名单开头 + 写关键字扫描 + 单语句 + PRAGMA 白名单。
|
|
215
|
+
*
|
|
216
|
+
* `engine` 决定反斜杠转义规则(见 stripSqlNoise);不传时按最保守的方式把
|
|
217
|
+
* 反斜杠当字面量,宁可误拒也不放过第二条语句。
|
|
218
|
+
*/
|
|
219
|
+
export function assertReadQuery(sql, engine) {
|
|
137
220
|
const trimmed = sql.trim();
|
|
138
|
-
const clean = stripSqlNoise(trimmed);
|
|
221
|
+
const clean = stripSqlNoise(trimmed, engine);
|
|
139
222
|
const first = /^[a-z]+/i.exec(clean.trim())?.[0]?.toLowerCase() ?? '';
|
|
140
223
|
if (!READ_KEYWORDS.test(clean.trim())) {
|
|
141
224
|
throw new Error('sql_query 只接受只读语句(SELECT / PRAGMA / EXPLAIN / SHOW / DESCRIBE / WITH)。写操作请用 sql_exec。');
|
|
@@ -145,8 +228,7 @@ export function assertReadQuery(sql) {
|
|
|
145
228
|
throw new Error('sql_query 一次只允许一条语句。');
|
|
146
229
|
const single = statements[0]?.trim() ?? '';
|
|
147
230
|
if (first === 'pragma') {
|
|
148
|
-
|
|
149
|
-
throw new Error('sql_query 不接受带赋值参数的 PRAGMA 写操作(如 PRAGMA journal_mode=WAL),请用 sql_exec。');
|
|
231
|
+
assertReadOnlyPragma(single);
|
|
150
232
|
}
|
|
151
233
|
else if (first === 'show' || first === 'describe' || first === 'desc') {
|
|
152
234
|
// SHOW / DESCRIBE 本身只读,不再扫写关键字(避免误伤 SHOW CREATE TABLE)。
|
|
@@ -163,6 +245,40 @@ export function assertReadQuery(sql) {
|
|
|
163
245
|
}
|
|
164
246
|
return trimmed.replace(/;+\s*$/, '').trim();
|
|
165
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* 把一段写操作脚本压成审批卡可读的摘要。
|
|
250
|
+
*
|
|
251
|
+
* 关键点:逐条列出每条语句,并额外给一行语句类型统计。即使脚本体被省略,
|
|
252
|
+
* 第二条、第 N 条语句的 DROP / DELETE 也不会从审批文本里消失。
|
|
253
|
+
*/
|
|
254
|
+
export function summarizeWriteScript(sql) {
|
|
255
|
+
const trimmed = sql.trim();
|
|
256
|
+
const clean = stripSqlNoise(trimmed);
|
|
257
|
+
const statements = clean
|
|
258
|
+
.split(';')
|
|
259
|
+
.map((part) => part.replace(/\s+/g, ' ').trim())
|
|
260
|
+
.filter((part) => part !== '');
|
|
261
|
+
const effective = statements.length === 0 ? ['(空语句)'] : statements;
|
|
262
|
+
const tally = new Map();
|
|
263
|
+
for (const statement of effective) {
|
|
264
|
+
const keyword = /^[a-z]+/i.exec(statement)?.[0]?.toUpperCase() ?? '?';
|
|
265
|
+
tally.set(keyword, (tally.get(keyword) ?? 0) + 1);
|
|
266
|
+
}
|
|
267
|
+
const lines = ['共 ' + effective.length + ' 条语句:'];
|
|
268
|
+
effective.slice(0, 100).forEach((statement, index) => {
|
|
269
|
+
lines.push(' ' + (index + 1) + '. ' + (statement.length > 120 ? statement.slice(0, 120) + '…' : statement));
|
|
270
|
+
});
|
|
271
|
+
if (effective.length > 100)
|
|
272
|
+
lines.push(' …另有 ' + (effective.length - 100) + ' 条语句');
|
|
273
|
+
lines.push('语句类型:' + [...tally.entries()].map(([keyword, count]) => keyword + '×' + count).join(','));
|
|
274
|
+
if (trimmed.length <= 800) {
|
|
275
|
+
lines.push('', '完整 SQL:' + trimmed);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
lines.push('', '完整 SQL 共 ' + trimmed.length + ' 个字符,见本次工具调用参数。');
|
|
279
|
+
}
|
|
280
|
+
return lines.join('\n');
|
|
281
|
+
}
|
|
166
282
|
const querySchema = {
|
|
167
283
|
type: 'object',
|
|
168
284
|
properties: {
|
|
@@ -289,15 +405,20 @@ const healthSchema = {
|
|
|
289
405
|
export function buildSqlTools(config) {
|
|
290
406
|
const cfg = config;
|
|
291
407
|
const adapters = new Map();
|
|
292
|
-
|
|
408
|
+
/** 只解析连接配置(不建连接),用于在打开数据库前挑选词法规则。 */
|
|
409
|
+
const findConnection = (name) => {
|
|
293
410
|
const target = name ?? cfg.connections[0].name;
|
|
294
411
|
const connection = cfg.connections.find((item) => item.name.toLowerCase() === target.toLowerCase());
|
|
295
412
|
if (connection === undefined) {
|
|
296
413
|
throw new Error('未找到名为 ' + target + ' 的数据库连接。可用 sql_list 查看连接清单。');
|
|
297
414
|
}
|
|
415
|
+
return connection;
|
|
416
|
+
};
|
|
417
|
+
const getAdapter = (name) => {
|
|
418
|
+
const connection = findConnection(name);
|
|
298
419
|
let adapter = adapters.get(connection.name);
|
|
299
420
|
if (adapter === undefined) {
|
|
300
|
-
adapter = createAdapter(connection);
|
|
421
|
+
adapter = createAdapter(connection, { readOnly: cfg.readOnly });
|
|
301
422
|
adapters.set(connection.name, adapter);
|
|
302
423
|
}
|
|
303
424
|
return { adapter, name: connection.name };
|
|
@@ -352,7 +473,7 @@ export function buildSqlTools(config) {
|
|
|
352
473
|
};
|
|
353
474
|
const sqlQuery = {
|
|
354
475
|
name: 'sql_query',
|
|
355
|
-
description: '执行只读 SQL 查询(SELECT / PRAGMA / EXPLAIN / SHOW / DESCRIBE / WITH
|
|
476
|
+
description: '执行只读 SQL 查询(SELECT / PRAGMA / EXPLAIN / SHOW / DESCRIBE / WITH)。词法级校验按引擎处理反斜杠转义,拒绝 data-modifying CTE、SELECT INTO、FOR UPDATE/FOR SHARE、非白名单 PRAGMA 与多语句;readOnly=true 时还会让数据库自身以只读方式连接。connection 为连接名(缺省第一个连接)。返回列名与行数据,最多 maxRows 行(超出 truncated=true)。写操作请用 sql_exec。',
|
|
356
477
|
parameters: compileParameters({
|
|
357
478
|
sql: { type: 'string', required: true, description: '只读 SQL 语句(必填,单条)。' },
|
|
358
479
|
connection: { type: 'string', description: '连接名(可选,缺省第一个连接)。' },
|
|
@@ -378,8 +499,11 @@ export function buildSqlTools(config) {
|
|
|
378
499
|
},
|
|
379
500
|
async execute(rawArgs, exec) {
|
|
380
501
|
const args = asRecord(rawArgs);
|
|
381
|
-
const
|
|
382
|
-
const
|
|
502
|
+
const rawSql = requiredString(args, 'sql', 'SQL 语句');
|
|
503
|
+
const connectionName = optionalString(args, 'connection');
|
|
504
|
+
// 先按目标连接的引擎做词法校验,再打开数据库:被拒绝的 SQL 不该去建连接或建库文件。
|
|
505
|
+
const sql = assertReadQuery(rawSql, findConnection(connectionName).engine);
|
|
506
|
+
const { adapter, name } = getAdapter(connectionName);
|
|
383
507
|
const result = await adapter.query(sql, cfg.maxRows + 1, executionSignal(exec));
|
|
384
508
|
const total = result.rows.length;
|
|
385
509
|
const rows = result.rows.slice(0, cfg.maxRows);
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// Generated: shared optional update controls; edit the original client templates.
|
|
2
|
+
(function(){
|
|
3
|
+
/* DSH workbench UI v1. MIT. Canonical source: dsh-suite/ui/workbench-ui.js.
|
|
4
|
+
* Vendored into each plugin: no Suite or extra runtime dependency is required.
|
|
5
|
+
* Styles are scoped to .dshw and never replace the host chrome or theme.
|
|
6
|
+
*/
|
|
7
|
+
function createWorkbenchUI(React) {
|
|
8
|
+
const h = React.createElement;
|
|
9
|
+
const { useEffect, useId, useRef } = React;
|
|
10
|
+
const cx = (...values) => values.filter(Boolean).join(' ');
|
|
11
|
+
const paths = {
|
|
12
|
+
book: ['M4 4h6a3 3 0 0 1 2 2v15a4 4 0 0 0-4-2H4z', 'M20 4h-6a3 3 0 0 0-2 2v15a4 4 0 0 1 4-2h4z'],
|
|
13
|
+
calendar: ['M5 5h14a2 2 0 0 1 2 2v13H3V7a2 2 0 0 1 2-2Z', 'M7 3v4M17 3v4M3 10h18'],
|
|
14
|
+
mail: ['M3 5h18v14H3z', 'm3 6 9 7 9-7'],
|
|
15
|
+
rss: ['M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16', 'M4 19h.01'],
|
|
16
|
+
slides: ['M3 4h18v13H3z', 'M12 17v4M8 21h8'],
|
|
17
|
+
shield: ['m12 3 8 3v6c0 4-4 7-8 9-4-2-8-5-8-9V6z', 'm8 12 3 3 5-6'],
|
|
18
|
+
layers: ['M5 3h16v16H5z', 'M2 7v15h15'],
|
|
19
|
+
play: ['m9 6 9 6-9 6Z'],
|
|
20
|
+
video: ['M3 5h12v14H3z', 'm15 10 6-4v12l-6-4'],
|
|
21
|
+
search: ['M10.5 18a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15Z', 'm16 16 5 5'],
|
|
22
|
+
plus: ['M12 5v14M5 12h14'],
|
|
23
|
+
refresh: ['M20 7a9 9 0 1 0 1 8', 'M20 2v5h-5'],
|
|
24
|
+
settings: ['m9 3-.7 2.4-2 .9L4 5.8 2.5 8.4l1.7 1.8v2.3L2.5 14 4 17l2.5-.4 1.9 1L9 20h3l.8-2.4 2-1 2.5.4 1.5-2.7-1.7-1.9v-2.2l1.7-1.8-1.5-2.6-2.5.4-2-1L12 3Z', 'M14 11.5a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z'],
|
|
25
|
+
close: ['m6 6 12 12M18 6 6 18'],
|
|
26
|
+
left: ['m14 5-7 7 7 7'],
|
|
27
|
+
right: ['m10 5 7 7-7 7'],
|
|
28
|
+
down: ['m5 9 7 7 7-7'],
|
|
29
|
+
filter: ['M4 5h16M7 12h10M10 19h4'],
|
|
30
|
+
download: ['M12 3v12m-5-5 5 5 5-5', 'M4 16v5h16v-5'],
|
|
31
|
+
upload: ['M12 16V4m-5 5 5-5 5 5', 'M4 16v5h16v-5'],
|
|
32
|
+
copy: ['M8 8h13v13H8z', 'M16 8V3H3v13h5'],
|
|
33
|
+
external: ['M14 3h7v7m0-7L10 14', 'M10 3H3v18h18v-7'],
|
|
34
|
+
file: ['M4 3h10l6 6v12H4z', 'M14 3v6h6M8 13h8M8 17h6'],
|
|
35
|
+
folder: ['M3 5h7l2 3h9v12H3z'],
|
|
36
|
+
edit: ['m15 4 5 5-10 10-6 1 1-6Z', 'm13 6 5 5'],
|
|
37
|
+
trash: ['M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7'],
|
|
38
|
+
more: ['M5 12h.01M12 12h.01M19 12h.01'],
|
|
39
|
+
info: ['M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z', 'M12 11v6M12 7h.01'],
|
|
40
|
+
check: ['m5 12 4 4L19 6'],
|
|
41
|
+
quote: ['M4 12h6v7H3v-7c0-4 2-6 5-7M15 12h6v7h-7v-7c0-4 2-6 5-7'],
|
|
42
|
+
star: ['m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-3-5.6 3 1.1-6.2L3 9.6l6.2-.9Z'],
|
|
43
|
+
reply: ['m9 5-7 7 7 7v-5h6c3 0 5 2 6 5 0-8-4-11-12-11Z'],
|
|
44
|
+
link: ['m10 13 4-4M8 16l-2 2a4 4 0 0 1-6-6l4-4a4 4 0 0 1 6 0', 'm14 8 2-2a4 4 0 0 1 6 6l-4 4a4 4 0 0 1-6 0'],
|
|
45
|
+
};
|
|
46
|
+
function Icon({ name = 'file', size = 20, ...props }) {
|
|
47
|
+
return h('svg', { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.75, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, focusable: false, ...props },
|
|
48
|
+
(paths[name] || paths.file).map((d, index) => h('path', { key: index, d })));
|
|
49
|
+
}
|
|
50
|
+
function Button({ children, icon, variant, className, ...props }) {
|
|
51
|
+
return h('button', { type: 'button', ...props, className: cx('w-button', variant && 'w-' + variant, className) }, icon ? h(Icon, { name: icon, size: 18 }) : null, children);
|
|
52
|
+
}
|
|
53
|
+
function IconButton({ label, icon, ...props }) {
|
|
54
|
+
return h(Button, { ...props, icon, className: cx('w-icon-button', props.className), 'aria-label': label, title: label });
|
|
55
|
+
}
|
|
56
|
+
function Page({ title, subtitle, actions, children, workbench = false, className, rootRef, ...props }) {
|
|
57
|
+
return h('section', { ...props, ref: rootRef, className: cx('dshw', 'w-page', className), 'data-main': workbench ? 'true' : 'false' },
|
|
58
|
+
h('style', null, styles),
|
|
59
|
+
h('header', { className: 'w-page-header' }, h('div', { className: 'w-heading' }, h('h1', null, title), subtitle ? h('p', { className: 'w-subtitle' }, subtitle) : null), actions ? h('div', { className: 'w-header-actions' }, actions) : null),
|
|
60
|
+
h('div', { className: 'w-body' }, children));
|
|
61
|
+
}
|
|
62
|
+
function Tabs({ items, value, onChange, label, children }) {
|
|
63
|
+
const id = useId();
|
|
64
|
+
const active = items.some(item => item.id === value) ? value : items[0]?.id;
|
|
65
|
+
function keys(event, index) {
|
|
66
|
+
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + items.length) % items.length;
|
|
69
|
+
const node = event.currentTarget.parentElement?.querySelectorAll('[role=tab]')[next];
|
|
70
|
+
if (node) { onChange(items[next].id); node.focus(); }
|
|
71
|
+
}
|
|
72
|
+
return h('div', { className: 'w-tabs-layout' },
|
|
73
|
+
h('div', { className: 'w-tabs', role: 'tablist', 'aria-label': label }, items.map((item, index) => h('button', {
|
|
74
|
+
type: 'button', role: 'tab', key: item.id, id: id + '-' + item.id, 'aria-controls': id + '-panel', 'aria-selected': active === item.id, tabIndex: active === item.id ? 0 : -1,
|
|
75
|
+
onClick: () => onChange(item.id), onKeyDown: event => keys(event, index),
|
|
76
|
+
}, item.label, item.count !== undefined ? h('span', { className: 'w-count' }, item.count) : null))),
|
|
77
|
+
h('div', { className: 'w-tab-panel', id: id + '-panel', role: 'tabpanel', 'aria-labelledby': id + '-' + active }, children));
|
|
78
|
+
}
|
|
79
|
+
function Status({ children, tone = 'info', className }) {
|
|
80
|
+
if (!children) return null;
|
|
81
|
+
return h('div', { className: cx('w-status', 'w-status-' + tone, className), role: tone === 'error' ? 'alert' : 'status', 'aria-live': tone === 'error' ? 'assertive' : 'polite' }, h(Icon, { name: tone === 'success' ? 'check' : 'info', size: 18 }), h('div', null, children));
|
|
82
|
+
}
|
|
83
|
+
function Empty({ title, children, action, icon = 'file' }) {
|
|
84
|
+
return h('div', { className: 'w-empty' }, h(Icon, { name: icon, size: 32 }), h('h2', null, title), children ? h('div', { className: 'w-muted' }, children) : null, action ? h('div', { className: 'w-toolbar' }, action) : null);
|
|
85
|
+
}
|
|
86
|
+
function Split({ list, detail, selected, onBack, backLabel, className, listWidth = 360 }) {
|
|
87
|
+
const ref = useRef(null);
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!selected || !ref.current || ref.current.clientWidth > 760) return;
|
|
90
|
+
const heading = ref.current.querySelector('.w-detail h2');
|
|
91
|
+
if (heading) { heading.tabIndex = -1; heading.focus({ preventScroll: true }); }
|
|
92
|
+
}, [selected]);
|
|
93
|
+
return h('div', { ref, className: cx('w-split', className), 'data-selected': selected ? 'true' : 'false', style: { '--w-list-width': listWidth + 'px' } },
|
|
94
|
+
h('div', { className: 'w-master' }, list),
|
|
95
|
+
h('div', { className: 'w-detail' }, selected ? h(Button, { className: 'w-back', icon: 'left', variant: 'ghost', onClick: onBack }, backLabel) : null, detail));
|
|
96
|
+
}
|
|
97
|
+
function Row({ title, meta, description, active, onClick, children, status, className, disabled }) {
|
|
98
|
+
return h('div', { className: cx('w-row', active && 'w-selected', className) },
|
|
99
|
+
children ? h('div', { className: 'w-row-select' }, children) : null,
|
|
100
|
+
h('button', { type: 'button', className: 'w-row-button', onClick, disabled, 'aria-pressed': !!active },
|
|
101
|
+
h('span', { className: 'w-row-head' }, h('strong', null, title), status ? h('span', { className: 'w-tag' }, status) : null),
|
|
102
|
+
meta ? h('span', { className: 'w-row-meta' }, meta) : null,
|
|
103
|
+
description ? h('span', { className: 'w-row-description' }, description) : null));
|
|
104
|
+
}
|
|
105
|
+
function Search({ label, value, onChange, ...props }) {
|
|
106
|
+
const id = useId();
|
|
107
|
+
return h('div', { className: 'w-search' }, h(Icon, { name: 'search', size: 18 }), h('input', { type: 'search', id, 'aria-label': label, placeholder: label, value, onChange, ...props }));
|
|
108
|
+
}
|
|
109
|
+
function Switch({ label, checked, onChange, disabled }) {
|
|
110
|
+
return h('label', { className: 'w-switch-label' }, h('span', null, label), h('input', { type: 'checkbox', role: 'switch', checked, disabled, onChange, 'aria-label': label }), h('span', { className: 'w-switch-track', 'aria-hidden': true }));
|
|
111
|
+
}
|
|
112
|
+
function Dialog({ title, children, onClose, closeLabel, className, busy = false }) {
|
|
113
|
+
const id = useId(), panel = useRef(null);
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const previous = document.activeElement;
|
|
116
|
+
panel.current?.querySelector('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]')?.focus();
|
|
117
|
+
return () => { if (previous?.isConnected) previous.focus({ preventScroll: true }); };
|
|
118
|
+
}, []);
|
|
119
|
+
function keydown(event) {
|
|
120
|
+
if (event.key === 'Escape' && !event.isComposing) { event.preventDefault(); event.stopPropagation(); if (!busy) onClose(); return; }
|
|
121
|
+
if (event.key !== 'Tab') return;
|
|
122
|
+
const controls = [...panel.current.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex="0"]')].filter(node => node.getClientRects().length);
|
|
123
|
+
const first = controls[0], last = controls.at(-1);
|
|
124
|
+
if (!controls.length) { event.preventDefault(); panel.current.focus(); }
|
|
125
|
+
else if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
|
126
|
+
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
|
127
|
+
}
|
|
128
|
+
return h('div', { className: 'w-overlay', onClick: event => { if (!busy && event.target === event.currentTarget) onClose(); } },
|
|
129
|
+
h('section', { ref: panel, className: cx('w-dialog', className), role: 'dialog', 'aria-modal': true, 'aria-labelledby': id, tabIndex: -1, onKeyDown: keydown },
|
|
130
|
+
h('header', { className: 'w-dialog-header' }, h('h2', { id }, title), h(IconButton, { icon: 'close', label: closeLabel, disabled: busy, onClick: onClose })), h('div', { className: 'w-dialog-body' }, children)));
|
|
131
|
+
}
|
|
132
|
+
function register(ctx, { id, label, icon, component, inject, order = 50 }) {
|
|
133
|
+
// The two slots are optional. Existing settings and manual tools still load
|
|
134
|
+
// in hosts without a workbench, while RC.3 owns navigation/pinning itself.
|
|
135
|
+
ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: id, inject: () => ({ ...(inject?.() || {}), workbench: true }) }, component));
|
|
136
|
+
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', id, order, label }, function WorkbenchIcon({ size }) { return h(Icon, { name: icon, size }); }));
|
|
137
|
+
}
|
|
138
|
+
const styles = `
|
|
139
|
+
.dshw{--w-bg:var(--dsw-alias-bg-layer-1,#fff);--w-soft:var(--dsw-alias-bg-layer-2,#f7f8fa);--w-text:var(--dsw-alias-label-primary,#18202c);--w-muted:var(--dsw-alias-label-secondary,#667085);--w-border:var(--dsw-alias-border-l3,#e7e9ef);--w-accent:var(--dsh-plugin-workbench-accent,#4f6bdb);--w-selected:color-mix(in srgb,var(--w-accent) 10%,var(--w-bg));--w-primary:var(--w-text);--w-on-primary:var(--w-bg);container-type:inline-size;container-name:dsh-workbench;color:var(--w-text);background:var(--w-bg);font:14px/1.65 var(--dsh-font-family-content,system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif);min-width:0;max-width:100%;box-sizing:border-box;text-align:start;color-scheme:inherit}
|
|
140
|
+
.dshw *,.dshw *:before,.dshw *:after{box-sizing:border-box}.dshw h1,.dshw h2,.dshw h3,.dshw h4,.dshw p{margin:0}.dshw h1{font-size:30px;line-height:1.25;letter-spacing:-.025em;font-weight:650}.dshw h2{font-size:24px;line-height:1.35;font-weight:650;letter-spacing:-.015em;overflow-wrap:anywhere}.dshw h3,.dshw h4{font-size:16px;line-height:1.4;font-weight:600}.dshw a{color:var(--w-accent);text-underline-offset:3px;overflow-wrap:anywhere}.dshw button,.dshw input,.dshw select,.dshw textarea{font:inherit;color:inherit;max-width:100%}.dshw input:not([type=checkbox]):not([type=radio]):not([type=file]),.dshw select,.dshw textarea{background:var(--w-bg);border:1px solid var(--w-border);border-radius:8px;padding:8px 11px;min-width:0;line-height:1.5}.dshw input:not([type=checkbox]):not([type=radio]):not([type=file]),.dshw select{min-height:38px}.dshw textarea{resize:vertical}.dshw input[type=checkbox],.dshw input[type=radio]{width:16px;height:16px;flex-shrink:0;accent-color:var(--w-accent)}.dshw :focus-visible{outline:2px solid var(--w-accent);outline-offset:3px}.dshw button:disabled,.dshw input:disabled,.dshw select:disabled,.dshw textarea:disabled{opacity:.5;cursor:not-allowed}.dshw label{font-size:13px}.dshw fieldset{min-width:0;border:1px solid var(--w-border);border-radius:8px}.dshw summary{cursor:pointer;font-weight:550;list-style:none;display:flex;align-items:center;gap:8px}.dshw summary::-webkit-details-marker{display:none}.dshw summary:after{content:'';display:block;margin-inline-start:auto;width:7px;height:7px;border-inline-end:1.75px solid currentColor;border-bottom:1.75px solid currentColor;transform:rotate(45deg);flex-shrink:0}.dshw details[open]>summary:after{transform:rotate(225deg)}.dshw details>summary+*{margin-top:12px}.dshw pre{white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.7 ui-monospace,SFMono-Regular,Consolas,monospace;max-width:100%;background:var(--w-soft);border-radius:6px;padding:14px}.dshw img,.dshw video,.dshw iframe{max-width:100%}
|
|
141
|
+
.w-page{display:flex;flex-direction:column;position:relative;padding:0;min-height:540px;gap:0}.w-page[data-main=true]{height:100%;min-height:0;padding:calc(24px + var(--dsh-frame-top-clearance,0px)) 28px 0 max(28px,var(--dsh-frame-leading-clearance,0px));overflow:auto}.w-page-header{display:flex;justify-content:space-between;gap:24px;align-items:flex-start;padding-bottom:22px;flex-shrink:0}.w-heading{min-width:0}.w-subtitle{margin-top:8px!important;font-size:14px;color:var(--w-muted);line-height:1.6}.w-header-actions,.w-toolbar,.w-actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px;min-width:0}.w-header-actions{justify-content:flex-end}.w-body,.w-tabs-layout{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0}.w-body{overflow:auto}.w-tab-panel{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;overflow:auto}.w-tabs{display:flex;align-items:stretch;gap:20px;border-bottom:1px solid var(--w-border);flex-shrink:0;overflow-x:auto;scrollbar-width:thin}.dshw .w-tabs>button{border:0;border-bottom:2px solid transparent;background:transparent;padding:11px 8px 13px;white-space:nowrap;font-size:14px;font-weight:550;cursor:pointer;color:var(--w-muted);display:flex;align-items:center;gap:7px}.dshw .w-tabs>button[aria-selected=true]{border-bottom-color:var(--w-accent);color:var(--w-accent)}.w-count,.w-tag{font-size:12px;line-height:1.5;display:inline-flex;align-items:center;flex-shrink:0;background:var(--w-soft);border-radius:5px;padding:1px 6px;color:var(--w-muted)}.w-tabs [aria-selected=true] .w-count,.w-selected .w-tag{color:var(--w-accent);background:var(--w-selected)}
|
|
142
|
+
.dshw .w-button{display:inline-flex;justify-content:center;align-items:center;gap:7px;background:var(--w-bg);border:1px solid var(--w-border);border-radius:8px;padding:8px 12px;min-height:36px;line-height:1.4;font-size:14px;font-weight:500;cursor:pointer;text-decoration:none;white-space:normal}.dshw .w-button:not(:disabled):hover{background:var(--w-soft)}.dshw .w-primary{background:var(--w-primary);border-color:var(--w-primary);color:var(--w-on-primary)}.dshw .w-primary:not(:disabled):hover{background:var(--w-primary);opacity:.9}.dshw .w-ghost{background:transparent;border-color:transparent}.dshw .w-danger{color:var(--dsw-alias-state-error-primary,#b43c3c)}.dshw .w-icon-button{width:36px;min-width:36px;padding:8px}.dshw .w-button svg{flex-shrink:0}.w-muted{color:var(--w-muted);font-size:12px;line-height:1.6}.w-section{padding:24px 0;border-bottom:1px solid var(--w-border);min-width:0}.w-section>h3{margin-bottom:12px}.w-section>p+p{margin-top:8px}.w-fields{display:grid;gap:16px;min-width:0}.w-field{display:grid;gap:6px;min-width:0}.w-field>input,.w-field>select,.w-field>textarea{width:100%}.w-status{display:flex;align-items:flex-start;gap:8px;padding:11px 13px;margin:12px 0;border:1px solid var(--w-border);border-radius:8px;background:var(--w-soft);font-size:13px;overflow-wrap:anywhere;flex-shrink:0}.w-status>svg{flex-shrink:0;margin-top:2px}.w-status-error{color:var(--dsw-alias-state-error-primary,#ad3636)}.w-status-success{color:var(--dsw-alias-state-success-primary,#24794a)}.w-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:14px;max-width:580px;padding:48px 24px;margin:auto;min-height:280px;overflow-wrap:anywhere}.w-empty>svg{color:var(--w-muted)}.w-empty h2{font-size:20px}.w-empty .w-muted{font-size:14px}.w-empty .w-toolbar{justify-content:center}
|
|
143
|
+
.w-split{display:grid;grid-template-columns:minmax(240px,var(--w-list-width)) minmax(0,1fr);min-height:460px;flex:1;min-width:0}.w-master{min-width:0;border-inline-end:1px solid var(--w-border);overflow:auto;padding-bottom:24px}.w-detail{min-width:0;padding:26px 28px;overflow:auto;overflow-wrap:anywhere;display:flex;flex-direction:column}.w-master-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:20px 16px}.w-search{display:flex;align-items:center;gap:8px;border:1px solid var(--w-border);border-radius:8px;padding:0 10px;min-width:0;flex:1;background:var(--w-bg)}.w-search>svg{color:var(--w-muted);flex-shrink:0}.dshw .w-search>input{border:0!important;background:transparent!important;padding:8px 0!important;width:100%;outline-offset:0}.w-row{display:flex;gap:0;min-width:0;position:relative;border-bottom:1px solid var(--w-border);border-inline-start:3px solid transparent}.w-row-select{display:flex;align-items:flex-start;padding:21px 0 0 14px}.dshw .w-row-button{display:flex;flex-direction:column;align-items:stretch;gap:7px;text-align:start;width:100%;padding:18px 18px 18px 16px;border:0;background:transparent;color:inherit;min-width:0;cursor:pointer;line-height:1.5}.dshw .w-row-button:hover{background:var(--w-soft)}.w-row-head{display:flex;align-items:flex-start;gap:10px;justify-content:space-between;min-width:0}.w-row-head>strong{font-size:14px;line-height:1.5;font-weight:600;overflow-wrap:anywhere}.w-row-meta,.w-row-description{color:var(--w-muted);font-size:12px;line-height:1.6;overflow-wrap:anywhere}.w-row-description{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.w-selected{background:var(--w-selected);border-inline-start-color:var(--w-accent)}.w-back{display:none!important}.w-detail-title{margin-bottom:12px!important}.w-detail-meta{color:var(--w-muted);font-size:13px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.w-detail-footer{padding:24px 0;display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:auto;border-top:1px solid var(--w-border)}.w-detail-footer .w-muted{flex:1;min-width:160px}.w-definition{display:grid;grid-template-columns:minmax(80px,120px) minmax(0,1fr);gap:8px 16px;font-size:14px;margin:20px 0}.w-definition dt{color:var(--w-muted)}.w-definition dd{margin:0;min-width:0;overflow-wrap:anywhere}
|
|
144
|
+
.w-switch-label{display:inline-flex;align-items:center;gap:8px;font-size:13px;position:relative;cursor:pointer}.w-switch-label>input{position:absolute;opacity:0;inset-inline-end:0;width:36px!important;height:22px!important;margin:0;cursor:pointer}.w-switch-track{width:36px;height:22px;border-radius:12px;display:block;background:var(--w-border);border:1px solid var(--w-border);pointer-events:none;padding:2px;transition:background .15s}.w-switch-track:before{content:'';display:block;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px #0002;transition:transform .15s}.w-switch-label>input:checked+.w-switch-track{background:var(--w-accent);border-color:var(--w-accent)}.w-switch-label>input:checked+.w-switch-track:before{transform:translateX(14px)}.w-switch-label>input:focus-visible+.w-switch-track{outline:2px solid var(--w-accent);outline-offset:3px}.w-switch-label:has(>input:disabled){opacity:.5;cursor:not-allowed}
|
|
145
|
+
.w-overlay{position:fixed;inset:0;background:#0005;display:flex;justify-content:flex-end;z-index:1000;padding:12px;overflow:auto}.w-dialog{width:min(620px,100%);max-height:100%;display:flex;flex-direction:column;min-height:0;background:var(--w-bg);color:var(--w-text);border:1px solid var(--w-border);border-radius:12px;box-shadow:0 18px 70px #0003}.w-dialog-header{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:20px 24px;border-bottom:1px solid var(--w-border);flex-shrink:0}.w-dialog-header>h2{font-size:20px}.w-dialog-body{overflow:auto;padding:20px 24px;min-height:0}.w-editor-grid{display:grid;grid-template-columns:210px minmax(0,1fr) 300px;min-height:500px;flex:1}.w-editor-list{padding:20px 12px;border-inline-end:1px solid var(--w-border);min-width:0}.w-editor-canvas{background:var(--w-soft);padding:20px;min-width:0;display:flex;flex-direction:column;gap:14px}.w-editor-inspector{padding:24px;border-inline-start:1px solid var(--w-border);min-width:0;overflow:auto}.w-editor-inspector h3{margin-bottom:18px}.w-preview-empty{display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;gap:12px;aspect-ratio:16/9;min-height:180px;background:var(--w-soft);border:1px solid var(--w-border);border-radius:8px;padding:24px;color:var(--w-muted)}.w-content-pad{padding:24px 0}.w-stack{display:flex;flex-direction:column;gap:16px;min-width:0}.w-scroll-x{overflow:auto;max-width:100%}
|
|
146
|
+
@container dsh-workbench (max-width:1000px){.w-editor-grid{grid-template-columns:160px minmax(0,1fr)}.w-editor-inspector{grid-column:1/-1;border-inline-start:0;border-top:1px solid var(--w-border)}.w-header-actions{max-width:60%}.w-split{grid-template-columns:minmax(240px,32%) minmax(0,1fr)}}
|
|
147
|
+
@container dsh-workbench (max-width:760px){.w-page-header{gap:14px;flex-wrap:wrap;padding-bottom:18px}.w-heading{flex:1;min-width:170px}.dshw h1{font-size:24px}.w-header-actions{max-width:100%;justify-content:flex-start}.w-subtitle{font-size:13px}.w-tabs{gap:12px}.dshw .w-tabs>button{padding:10px 4px 12px}.w-split{display:block;min-height:360px}.w-split[data-selected=false]>.w-detail{display:none}.w-split[data-selected=true]>.w-master{display:none}.w-master{border-inline-end:0}.w-detail{padding:20px 0}.w-back{display:inline-flex!important;align-self:flex-start;margin:0 0 16px!important}.w-detail h2{font-size:22px}.w-master-toolbar{padding:16px 0}.dshw .w-row-button{padding:16px 10px}.w-detail-footer{position:static}.w-detail-footer .w-muted{flex-basis:100%}.w-editor-grid{display:flex;flex-direction:column}.w-editor-list{border-inline-end:0;border-bottom:1px solid var(--w-border);padding:16px 0}.w-editor-canvas{padding:16px 0;background:var(--w-bg)}.w-editor-inspector{padding:20px 0}.w-dialog-header,.w-dialog-body{padding:16px}.w-button{min-height:40px}.w-definition{grid-template-columns:80px minmax(0,1fr)}}
|
|
148
|
+
@media(max-width:700px){.w-page[data-main=true]{padding:calc(16px + var(--dsh-frame-top-clearance,0px)) 16px 0 max(16px,var(--dsh-frame-leading-clearance,0px))}.w-overlay{padding:8px}.w-dialog{width:100%}}
|
|
149
|
+
@media(prefers-reduced-motion:reduce){.dshw *,.dshw *:before{scroll-behavior:auto!important;transition:none!important;animation:none!important}}
|
|
150
|
+
`;
|
|
151
|
+
return { Icon, Button, IconButton, Page, Tabs, Status, Empty, Split, Row, Search, Switch, Dialog, register, styles };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* One update control per plugin. Only transport, idle guards and installation
|
|
155
|
+
* coordination are shared; no global update page or update-all action exists. */
|
|
156
|
+
function __stardustUpdatesGuards() {
|
|
157
|
+
const key = Symbol.for('stardustlc.plugin-updates.guards.v1');
|
|
158
|
+
return globalThis[key] ||= { values: new Map(), listeners: new Set(), viewListeners: new Set(), clients: new Set() };
|
|
159
|
+
}
|
|
160
|
+
function __stardustUpdatesGuard(React, name, { dirty = false, busy = false, retainOnClose = false } = {}) {
|
|
161
|
+
const token = React.useRef(null);
|
|
162
|
+
if (!token.current) token.current = {};
|
|
163
|
+
React.useLayoutEffect(() => {
|
|
164
|
+
const pool = __stardustUpdatesGuards();
|
|
165
|
+
if (!dirty && !busy) for (const [key, value] of pool.values) if (value.name === name && value.orphan) pool.values.delete(key);
|
|
166
|
+
pool.values.set(token.current, { name, dirty: !!dirty, busy: !!busy, retainOnClose, orphan: false });
|
|
167
|
+
for (const listener of pool.listeners) listener();
|
|
168
|
+
}, [name, !!dirty, !!busy, retainOnClose]);
|
|
169
|
+
React.useEffect(() => () => {
|
|
170
|
+
const pool = __stardustUpdatesGuards(), value = pool.values.get(token.current);
|
|
171
|
+
if (value?.retainOnClose && (value.dirty || value.busy)) value.orphan = true;
|
|
172
|
+
else pool.values.delete(token.current);
|
|
173
|
+
for (const listener of pool.listeners) listener();
|
|
174
|
+
}, []);
|
|
175
|
+
}
|
|
176
|
+
function __StardustPluginUpdateButton({ React, name }) {
|
|
177
|
+
const [, render] = React.useState(0), pool = __stardustUpdatesGuards();
|
|
178
|
+
React.useEffect(() => { const fn = () => render(value => value + 1); pool.viewListeners.add(fn); return () => pool.viewListeners.delete(fn); }, []);
|
|
179
|
+
const client = [...pool.clients].find(client => client.has(name));
|
|
180
|
+
if (!client) return null;
|
|
181
|
+
const available = client.row(name)?.available;
|
|
182
|
+
return React.createElement(client.Button, { icon: 'refresh', variant: available ? undefined : 'ghost', onClick: () => client.open(name), 'data-plugin-update': name }, available ? client.t('有更新', 'Update available') : client.t('插件更新', 'Update plugin'));
|
|
183
|
+
}
|
|
184
|
+
function __stardustUpdatesPage(React, props, name) {
|
|
185
|
+
return { ...props, actions: React.createElement(React.Fragment, null, props.actions, React.createElement(__StardustPluginUpdateButton, { React, name })) };
|
|
186
|
+
}
|
|
187
|
+
function __stardustUpdatesDisplayRows(next, known) {
|
|
188
|
+
for (const row of next.rows) known.set(row.name, row);
|
|
189
|
+
if (!['verifying', 'installing', 'cancelling', 'unknown', 'restore-failed'].includes(next.job?.state)) return next;
|
|
190
|
+
const rows = [...next.rows];
|
|
191
|
+
for (const row of known.values()) if ((row.name === next.job.name || row.bundledBy === next.job.name) && !rows.some(item => item.name === row.name)) rows.push({ ...row, updating: true });
|
|
192
|
+
return { ...next, rows };
|
|
193
|
+
}
|
|
194
|
+
function __stardustUpdatesWrap(definition, participant) {
|
|
195
|
+
return { ...definition, factory(require) {
|
|
196
|
+
const original = definition.factory(require);
|
|
197
|
+
if (!original || typeof original !== 'object') return original;
|
|
198
|
+
const React = require('react');
|
|
199
|
+
return { ...original, apply(ctx, ...args) {
|
|
200
|
+
const result = original.apply?.call(this, ctx, ...args);
|
|
201
|
+
if (typeof ctx.inject === 'function') ctx.inject(['slots', 'locale', 'remote', 'remote.pluginManager'], host => __stardustUpdatesJoin(host, React, require, participant));
|
|
202
|
+
return result;
|
|
203
|
+
} };
|
|
204
|
+
} };
|
|
205
|
+
}
|
|
206
|
+
function __stardustUpdatesJoin(host, React, require, participant) {
|
|
207
|
+
const key = Symbol.for('stardustlc.plugin-updates.client.v1'), pools = globalThis[key] ||= new WeakMap(), root = host.root || host;
|
|
208
|
+
if (typeof root.plugin !== 'function') return;
|
|
209
|
+
let record = pools.get(root);
|
|
210
|
+
if (!record) {
|
|
211
|
+
record = { refs: new Map(), timer: null, version: participant.version, alive: true, hold: false };
|
|
212
|
+
pools.set(root, record);
|
|
213
|
+
const shared = record;
|
|
214
|
+
record.fiber = root.plugin({ name: 'stardustlc-plugin-updates-client', inject: ['slots', 'locale', 'remote', 'remote.pluginManager'], apply(ctx) {
|
|
215
|
+
const client = __stardustUpdatesClient(ctx, React, require, shared);
|
|
216
|
+
ctx.effect(() => () => { shared.alive = false; client.dispose(); if (pools.get(root) === shared) pools.delete(root); });
|
|
217
|
+
} });
|
|
218
|
+
Promise.resolve(record.fiber).catch(() => { shared.alive = false; if (pools.get(root) === shared) pools.delete(root); });
|
|
219
|
+
}
|
|
220
|
+
clearTimeout(record.timer);
|
|
221
|
+
const ref = {}; record.refs.set(ref, participant);
|
|
222
|
+
host.effect(() => () => {
|
|
223
|
+
record.refs.delete(ref);
|
|
224
|
+
const stop = () => {
|
|
225
|
+
if (record.refs.size || !record.alive) return;
|
|
226
|
+
if (record.hold) { record.timer = setTimeout(stop, 1000); return; }
|
|
227
|
+
record.alive = false; Promise.resolve(record.fiber?.dispose?.()).catch(() => {});
|
|
228
|
+
};
|
|
229
|
+
if (!record.refs.size) record.timer = setTimeout(stop, 1000);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
function __stardustUpdatesClient(ctx, React, require, shared) {
|
|
233
|
+
const h = React.createElement, W = createWorkbenchUI(React), route = new URL('api/stardustlc/plugin-updates', document.baseURI);
|
|
234
|
+
const { useState, useEffect } = React, id = crypto.randomUUID(), listeners = new Set(), pool = __stardustUpdatesGuards(), requests = new Set(), sections = new Map(), notified = new Set(), knownRows = new Map();
|
|
235
|
+
let alive = true, registered = false, state = null, lastInteraction = Date.now(), inPoll = false, activeName = null, manualOperation = false;
|
|
236
|
+
const language = () => String(ctx.locale?.getSnapshot?.().active || '').startsWith('en') ? 'en' : 'zh';
|
|
237
|
+
const t = (zh, en) => language() === 'en' ? en : zh;
|
|
238
|
+
const preferences = name => state?.preferences.packages[name] || { checkEnabled: true, autoInstall: false, deferredUntil: 0, ignoredVersion: null };
|
|
239
|
+
const originalRow = name => state?.rows.find(row => row.name === name);
|
|
240
|
+
const rowFor = name => { const row = originalRow(name); return row?.bundledBy ? originalRow(row.bundledBy) : row; };
|
|
241
|
+
const activeJob = () => ['verifying', 'installing', 'cancelling'].includes(state?.job?.state);
|
|
242
|
+
function notify() { shared.hold = manualOperation || activeJob(); for (const listener of listeners) listener(); for (const listener of pool.viewListeners) listener(); }
|
|
243
|
+
async function api(body = { action: 'state' }) {
|
|
244
|
+
const controller = new AbortController(); requests.add(controller);
|
|
245
|
+
const timeout = setTimeout(() => controller.abort(), body.action === 'check' ? 95000 : 45000);
|
|
246
|
+
try {
|
|
247
|
+
const response = await fetch(route, { method: 'POST', credentials: 'same-origin', cache: 'no-store', signal: controller.signal, headers: { 'content-type': 'application/json', 'x-stardustlc-updates': '1' }, body: JSON.stringify(body) });
|
|
248
|
+
let data; try { data = await response.json(); } catch { throw new Error('service-unavailable'); }
|
|
249
|
+
if (!response.ok || !data.ok) throw new Error(response.status === 403 ? 'owner-required' : data.code || 'service-unavailable');
|
|
250
|
+
return data.value;
|
|
251
|
+
} catch (error) { if (error?.name === 'AbortError') throw new Error('service-unavailable'); throw error; }
|
|
252
|
+
finally { clearTimeout(timeout); requests.delete(controller); }
|
|
253
|
+
}
|
|
254
|
+
function pageState(manual = manualOperation) {
|
|
255
|
+
const values = [...pool.values.values()];
|
|
256
|
+
const dialog = [...document.querySelectorAll('[role="dialog"],dialog[open]')].some(node => node.getClientRects().length && !(manual && (node.closest('.plugin-updates-page') || node.querySelector('.plugin-updates-page'))));
|
|
257
|
+
return { action: 'heartbeat', id, dirty: values.some(value => value.dirty), busy: values.some(value => value.busy) || dialog, idleForMs: Math.min(86400000, Math.max(0, Date.now() - lastInteraction)) };
|
|
258
|
+
}
|
|
259
|
+
const heartbeat = () => api(pageState());
|
|
260
|
+
function interaction() { lastInteraction = Date.now(); }
|
|
261
|
+
const guardChanged = () => { if (registered && alive) void heartbeat().catch(() => {}); };
|
|
262
|
+
for (const event of ['pointerdown', 'keydown', 'input']) document.addEventListener(event, interaction, { capture: true, passive: true });
|
|
263
|
+
pool.listeners.add(guardChanged);
|
|
264
|
+
const close = () => { void fetch(route, { method: 'POST', credentials: 'same-origin', keepalive: true, headers: { 'content-type': 'application/json', 'x-stardustlc-updates': '1' }, body: JSON.stringify({ action: 'client-close', id }) }).catch(() => {}); };
|
|
265
|
+
window.addEventListener('pagehide', close);
|
|
266
|
+
async function refresh() { const next = await api(); if (alive) { state = __stardustUpdatesDisplayRows(next, knownRows); notify(); } return next; }
|
|
267
|
+
function useSnapshot() {
|
|
268
|
+
const [, render] = useState(0);
|
|
269
|
+
useEffect(() => { const fn = () => render(value => value + 1); listeners.add(fn); const stop = ctx.locale?.subscribe?.(fn); return () => { listeners.delete(fn); stop?.(); }; }, []);
|
|
270
|
+
return state;
|
|
271
|
+
}
|
|
272
|
+
function message(code) {
|
|
273
|
+
return ({
|
|
274
|
+
'page-dirty': t('有未保存的修改。请先保存或放弃修改,再更新。', 'Save or discard unsaved changes before updating.'),
|
|
275
|
+
'page-busy': t('有页面操作或对话框尚未结束,请完成后再试。', 'Finish the active page operation or close its dialog, then retry.'),
|
|
276
|
+
'page-not-ready': t('还有窗口状态未确认,或旧版插件尚未提供状态保护。请刷新相关窗口,必要时先手动更新旧版插件。', 'A window state is unknown or an older plugin lacks state protection. Refresh the affected windows and manually update legacy plugins if needed.'),
|
|
277
|
+
busy: t('DSH 正在执行任务或安装,请等操作结束。', 'DSH is running a task or installation. Wait for it to finish.'),
|
|
278
|
+
'activity-unknown': t('无法确认任务是否空闲,已暂停安装。', 'Installation paused because task activity is unknown.'),
|
|
279
|
+
'restart-required': t('更新服务需要重启后继续。请在方便时手动重启 DSH。', 'The updater needs a restart. Restart DSH manually when convenient.'),
|
|
280
|
+
'operation-unconfirmed': t('上次安装未确认或恢复失败。请在官方插件管理中检查并修复,之后手动重启 DSH。', 'The last installation is unconfirmed or recovery failed. Check and repair it in native plugin management, then restart DSH manually.'),
|
|
281
|
+
'check-first': t('检查结果已过期,请重新检查。', 'This check expired. Check again.'),
|
|
282
|
+
'release-changed': t('发布信息已变化,本次未安装。请重新检查。', 'Release metadata changed. Nothing was installed; check again.'),
|
|
283
|
+
'preferences-conflict': t('另一个窗口修改了偏好,已刷新,请重试。', 'Preferences changed in another window. They have been refreshed; retry.'),
|
|
284
|
+
'preferences-unreadable': t('更新偏好文件无法读取,自动安装已关闭。请检查当前 profile 的 stardustlc-plugin-updates.json。', 'Preferences could not be read. Automatic installation is off. Check stardustlc-plugin-updates.json in this profile.'),
|
|
285
|
+
'no-update': t('版本已变化或暂不兼容,请重新检查。', 'The version changed or is incompatible. Check again.'),
|
|
286
|
+
'source-not-registry': t('本地源码、Git 等安装,请通过原安装方式更新。', 'Update local, Git and other external sources through their original installation method.'),
|
|
287
|
+
'managed-by-suite': t('这个插件随 Suite 安装,通过 Suite 更新。', 'This plugin was installed through Suite and is updated through Suite.'),
|
|
288
|
+
'owner-required': t('只有 profile 拥有者能管理更新。', 'Only the profile owner can manage updates.'),
|
|
289
|
+
})[code] || t('操作未完成,请刷新后重试。DSH 不会因此自动重启。', 'The operation could not be completed. Refresh and retry. DSH will not restart automatically.');
|
|
290
|
+
}
|
|
291
|
+
function jobLabel(job) {
|
|
292
|
+
return ({ verifying: t('正在核对版本', 'Verifying release'), installing: t('正在安装', 'Installing'), cancelling: t('正在请求取消', 'Requesting cancellation'), complete: t('已更新', 'Updated'), 'restart-required': t('已安装,等待你手动重启', 'Installed; manual restart needed'), restored: t('更新未成功,已恢复原版本', 'Update failed; previous version restored'), 'restore-failed': t('恢复未成功,请检查官方插件管理', 'Recovery failed; check native plugin management'), unknown: t('结果未确认,请检查官方插件管理', 'Result unconfirmed; check native plugin management'), cancelled: t('已取消', 'Cancelled'), failed: t('更新未成功', 'Update failed') })[job.state] || job.state;
|
|
293
|
+
}
|
|
294
|
+
function Panel({ name, compact = false }) {
|
|
295
|
+
const snapshot = useSnapshot(), [error, setError] = useState(''), [working, setWorking] = useState(false), [checking, setChecking] = useState(false), [dialog, setDialog] = useState(null), [notes, setNotes] = useState(null), [loadingNotes, setLoadingNotes] = useState(false);
|
|
296
|
+
const row = rowFor(name), original = originalRow(name), target = row?.name || name, prefs = preferences(target), job = snapshot?.job?.name === target ? snapshot.job : null;
|
|
297
|
+
async function perform(callback) { setError(''); setWorking(true); try { await callback(); await refresh(); } catch (error) { setError(error.message); await refresh().catch(() => {}); } finally { setWorking(false); } }
|
|
298
|
+
async function save(changes, consent) { await api({ action: 'preferences', name: target, revision: state.preferences.revision, changes, consent }); }
|
|
299
|
+
async function check() { setError(''); setChecking(true); try { await api({ action: 'check', name: target }); await refresh(); } catch (error) { setError(error.message); } finally { setChecking(false); } }
|
|
300
|
+
async function install() {
|
|
301
|
+
setDialog(null); manualOperation = true; notify();
|
|
302
|
+
await perform(async () => {
|
|
303
|
+
try {
|
|
304
|
+
await heartbeat();
|
|
305
|
+
const started = await api({ action: 'install', name: row.name, version: row.release.version, confirmation: 'update-installed-plugin' });
|
|
306
|
+
while (alive) {
|
|
307
|
+
await new Promise(resolve => setTimeout(resolve, 800)); await heartbeat(); await refresh();
|
|
308
|
+
if (state.job?.id !== started.id) throw new Error('operation-unconfirmed');
|
|
309
|
+
if (!activeJob()) break;
|
|
310
|
+
}
|
|
311
|
+
} finally { manualOperation = false; notify(); }
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
async function showNotes() {
|
|
315
|
+
setDialog('notes'); setNotes(null); setLoadingNotes(true); setError('');
|
|
316
|
+
try { setNotes(await api({ action: 'notes', name: target, version: row.release.version })); } catch (error) { setError(error.message); } finally { setLoadingNotes(false); }
|
|
317
|
+
}
|
|
318
|
+
if (!snapshot || !row) return h(W.Status, null, t('此插件尚未安装或已从当前 profile 移除。', 'This plugin is not installed in the current profile.'));
|
|
319
|
+
const title = language() === 'en' ? original.labelEn : original.label;
|
|
320
|
+
if (compact) return h('div', { className: 'dshw', style: { padding: 16 } }, h('style', null, W.styles), h('div', { className: 'w-stack' }, h('h3', null, title), h('p', { className: 'w-muted' }, original.name + ' · ' + original.version), h('p', null, t('这个插件的更新设置与安装入口。自动安装默认关闭。', 'Update settings and installation for this plugin. Automatic installation is off by default.')), h(W.Button, { icon: 'refresh', onClick: () => { activeName = name; notify(); } }, row.available ? t('查看可用更新', 'Review available update') : t('检查与更新', 'Check and update'))));
|
|
321
|
+
const unconfirmed = ['unknown', 'restore-failed'].includes(snapshot.job?.state), pageBusy = pageState(true).busy || (snapshot.pages.clients || []).some(client => client.id !== id && client.busy), installLocked = working || activeJob() || unconfirmed || snapshot.updaterRestart || snapshot.preferenceError || !snapshot.activity.known || snapshot.activity.busy || snapshot.pages.dirty || pageBusy || !snapshot.pages.known;
|
|
322
|
+
const checkingThis = checking || snapshot.checkingNames?.includes(target);
|
|
323
|
+
const status = row.sourceKind !== 'registry' ? t('本地 / 外部来源,保留原安装方式', 'Local / external source; keep original install method') : row.loadError ? t('加载异常,请先检查官方插件管理', 'Loading error; check native plugin management') : row.error ? t('检查失败,可以重试', 'Check failed; retry available') : row.release && !row.release.compatible ? t('新版本暂不兼容当前环境', 'Latest release is incompatible with this environment') : row.available ? (prefs.ignoredVersion === row.release.version ? t('已忽略提醒,仍可手动更新', 'Reminder ignored; manual update available') : t('有新版本', 'Update available')) : row.checkedAt ? t('当前已是最新稳定版', 'Latest stable version') : t('尚未检查', 'Not checked yet');
|
|
324
|
+
return h(W.Page, { title: title + t(' · 更新', ' · Updates'), subtitle: t('只管理这个插件。检查不会安装,也不会重启 DSH。', 'Manage this plugin. Checking does not install anything or restart DSH.'), className: 'plugin-updates-page', actions: h(W.Button, { icon: 'refresh', disabled: checkingThis || row.sourceKind !== 'registry', onClick: check }, checkingThis ? t('正在检查…', 'Checking…') : t('检查更新', 'Check for updates')) },
|
|
325
|
+
h('style', null, '.plugin-updates-page{min-height:0;padding:4px}.plugin-updates-page .updates-settings{display:grid;gap:24px;padding:20px 0}.plugin-updates-page .updates-note{margin-top:8px}.plugin-updates-page .updates-version{font:13px/1.7 ui-monospace,monospace;overflow-wrap:anywhere}.plugin-updates-page .w-overlay{z-index:1002}.plugin-updates-page .w-switch-label{max-width:100%}.plugin-updates-page .w-switch-track{flex-shrink:0}@media(max-width:700px){[data-shortcut-modal="settings"]:has(.plugin-updates-page){flex-direction:column!important;width:calc(100vw - 16px)!important;max-width:calc(100vw - 16px)!important}[data-shortcut-modal="settings"]:has(.plugin-updates-page)>nav{width:100%!important;flex-basis:auto!important;flex-shrink:0;max-height:180px;overflow:auto;border-right:0!important}[data-shortcut-modal="settings"]:has(.plugin-updates-page)>nav>div:has(>button){display:flex;flex-wrap:wrap;gap:4px}[data-shortcut-modal="settings"]:has(.plugin-updates-page)>div{min-width:0!important;width:100%!important}.plugin-updates-page .w-switch-label{display:flex;justify-content:space-between;align-items:flex-start;gap:16px}.plugin-updates-page .w-switch-label>span:first-child{min-width:0}.plugin-updates-page .w-switch-label>input{inset-inline-end:0}}'),
|
|
326
|
+
original.bundledBy && h(W.Status, null, t('此插件随 Suite 安装。以下设置与安装作用于 Suite 整包,以保持组件版本一致。单独安装的插件可以独立更新。', 'This plugin was installed through Suite. These controls update Suite as a package so its components stay consistent. Independently installed plugins update individually.')),
|
|
327
|
+
h('section', { className: 'w-section' }, h('p', { className: 'updates-version' }, target + ' · ' + row.version, row.release?.version && row.release.version !== row.version ? ' → ' + row.release.version : ''), h('p', { className: 'w-muted updates-note' }, status), !row.enabled && h('p', { className: 'w-muted' }, t('当前已停用,更新后仍保持停用。', 'Currently disabled; it will remain disabled after updating.')), row.checkedAt && h('p', { className: 'w-muted' }, t('上次检查:', 'Last checked: ') + new Date(row.checkedAt).toLocaleString(language() === 'en' ? 'en-US' : 'zh-CN')),
|
|
328
|
+
h('div', { className: 'w-toolbar', style: { marginTop: 16 } }, row.available && row.sourceKind === 'registry' && h(W.Button, { variant: 'primary', icon: 'download', disabled: installLocked || row.loadError, onClick: () => setDialog('install') }, t('更新此插件', 'Update this plugin')), row.release && h(W.Button, { variant: 'ghost', onClick: showNotes }, t('更新记录', 'Release notes')), row.available && h(W.Button, { variant: 'ghost', disabled: working, onClick: () => perform(() => api({ action: 'later', name: target, revision: state.preferences.revision })) }, t('明天再提醒', 'Remind me tomorrow')), row.available && prefs.ignoredVersion !== row.release.version && h(W.Button, { variant: 'ghost', disabled: working, onClick: () => perform(() => api({ action: 'ignore', name: target, revision: state.preferences.revision, version: row.release.version })) }, t('忽略此版本提醒', 'Ignore this reminder')))),
|
|
329
|
+
h('section', { className: 'updates-settings', 'aria-label': t('这个插件的更新偏好', 'Update preferences for this plugin') },
|
|
330
|
+
h('div', null, h(W.Switch, { label: t('自动检查这个插件的更新', 'Automatically check this plugin'), checked: prefs.checkEnabled, disabled: working || !!snapshot.preferenceError || row.sourceKind !== 'registry', onChange: event => perform(() => save({ checkEnabled: event.target.checked })) }), h('p', { className: 'w-muted updates-note' }, t('默认开启,每天最多一次。只影响这个插件,关闭后仍可手动检查。', 'On by default, at most once daily. This affects only this plugin; manual checks remain available.'))),
|
|
331
|
+
h('div', null, h(W.Switch, { label: t('空闲时自动安装这个插件的补丁', 'Install patches for this plugin while idle'), checked: prefs.autoInstall, disabled: working || !!snapshot.preferenceError || row.sourceKind !== 'registry', onChange: event => event.target.checked ? setDialog('automatic') : perform(() => save({ autoInstall: false })) }), h('p', { className: 'w-muted updates-note' }, t('默认关闭。明确开启后,仅空闲且没有未保存修改时安装兼容补丁。其他插件的开关不变。', 'Off by default. Explicitly enable compatible patches while idle with no unsaved changes. Other plugins’ settings stay unchanged.')))),
|
|
332
|
+
error && h(W.Status, { tone: 'error' }, message(error)), snapshot.preferenceError && h(W.Status, { tone: 'error' }, message('preferences-unreadable')),
|
|
333
|
+
snapshot.pendingRestart && h(W.Status, null, snapshot.updaterRestart ? message('restart-required') : t('已有更新等待手动重启后生效。你可以继续工作,自动安装已暂停。', 'Some updates need a manual restart. Continue your work; automatic installation is paused.')),
|
|
334
|
+
prefs.autoInstall && snapshot.autoReady === false && h(W.Status, null, message('page-not-ready')),
|
|
335
|
+
snapshot.pages.clients.some(client => !client.fresh) && h(W.Status, null, h('p', null, t('有窗口已断开,无法确认是否还有未保存修改。仍在使用的窗口请先保存并刷新;已关闭的窗口可以手动重新确认。', 'A disconnected window may contain unsaved changes. Save and refresh windows still in use. If they are closed, you can confirm that manually.')), h(W.Button, { onClick: () => setDialog('windows') }, t('重新确认已关闭的窗口', 'Confirm closed windows'))),
|
|
336
|
+
snapshot.pages.dirty && h(W.Status, null, message('page-dirty')), (snapshot.activity.busy || pageBusy) && h(W.Status, null, message(snapshot.activity.busy ? 'busy' : 'page-busy')),
|
|
337
|
+
prefs.deferredUntil > Date.now() && h(W.Status, null, t('此插件已推迟提醒和自动安装 24 小时,仍可手动更新。', 'Reminders and automatic installation for this plugin are deferred for 24 hours. Manual updates remain available.')),
|
|
338
|
+
unconfirmed && h(W.Status, { tone: 'error' }, message('operation-unconfirmed')),
|
|
339
|
+
job && h('section', { className: 'w-section', 'aria-label': t('这个插件的更新进度', 'Update progress for this plugin') }, h('h3', null, jobLabel(job)), h('p', { className: 'w-muted' }, job.from + ' → ' + job.to + (job.mode === 'automatic' ? t(' · 自动补丁更新', ' · Automatic patch') : '')), (job.reason || job.outcome?.result?.error?.code) && h(W.Status, { tone: 'error' }, message(job.reason || job.outcome.result.error.code)), activeJob() && h(W.Button, { disabled: job.state === 'cancelling', onClick: () => perform(() => api({ action: 'cancel', id: job.id })) }, t('取消更新', 'Cancel update')), h('p', { className: 'w-muted updates-note' }, t('安装开始后,取消由官方包管理器处理;已落盘的写入以实际结果或恢复状态为准。', 'After installation starts, the native package manager handles cancellation. Completed writes and recovery are reported as they occur.'))),
|
|
340
|
+
dialog && h(W.Dialog, { title: dialog === 'windows' ? t('断开的窗口都已关闭?', 'Are the disconnected windows closed?') : dialog === 'automatic' ? t('允许这个插件自动安装补丁?', 'Allow automatic patches for this plugin?') : dialog === 'notes' ? t('更新记录', 'Release notes') : t('确认更新此插件', 'Confirm this plugin update'), closeLabel: t('关闭', 'Close'), onClose: () => setDialog(null) },
|
|
341
|
+
dialog === 'windows' ? h('div', { className: 'w-stack' }, h('p', null, t('只清除超过 45 秒未回应的其他窗口记录。请先确认它们已经关闭;仍在使用的窗口请保存修改并刷新。当前或仍在回应的窗口保护会保留。', 'Only other windows silent for over 45 seconds are cleared. Confirm that they are closed; save and refresh windows still in use. Current and responsive windows remain protected.')), h('div', { className: 'w-toolbar' }, h(W.Button, { onClick: () => setDialog(null) }, t('取消', 'Cancel')), h(W.Button, { onClick: () => { setDialog(null); void perform(() => api({ ...pageState(true), action: 'reconfirm-windows', confirmation: 'other-stale-windows-are-closed' })); } }, t('已关闭,重新确认状态', 'They are closed; reconfirm')))) :
|
|
342
|
+
dialog === 'automatic' ? h('div', { className: 'w-stack' }, h('p', null, (language() === 'en' ? row.labelEn : row.label) + ' · ' + target), h('p', null, t('你正在主动启用这个插件的自动安装。只处理兼容的稳定补丁;有任务、未保存修改、未知窗口或等待重启时暂停。', 'You are explicitly enabling automatic installation for this plugin. Only compatible stable patches qualify; running tasks, unsaved changes, unknown windows or a pending restart pause it.')), h('p', null, t('不会自动重启 DSH,其他插件不会跟着开启。可随时关闭。', 'DSH will not restart automatically, and other plugins will not be enabled for automatic updates. Turn it off anytime.')), h('div', { className: 'w-toolbar' }, h(W.Button, { onClick: () => setDialog(null) }, t('保持关闭', 'Keep off')), h(W.Button, { variant: 'primary', onClick: () => { setDialog(null); void perform(() => save({ checkEnabled: true, autoInstall: true }, 'enable-idle-patch-updates')); } }, t('允许此插件自动安装补丁', 'Allow patches for this plugin')))) :
|
|
343
|
+
dialog === 'notes' ? h('div', { className: 'w-stack' }, h('p', null, target + ' · ' + row.release.version), loadingNotes ? h('p', null, t('正在读取…', 'Loading…')) : notes?.text ? h('pre', null, notes.text) : h('p', null, t('未找到此版本的独立更新记录。', 'No separate notes were found for this version.')), notes?.url && h('a', { href: notes.url, target: '_blank', rel: 'noreferrer noopener' }, t('查看仓库更新记录', 'View repository changelog'))) :
|
|
344
|
+
h('div', { className: 'w-stack' }, h('p', { className: 'updates-version' }, target + ': ' + row.version + ' → ' + row.release.version), h('p', null, t('通过 DSH 官方插件管理器安装。需要重启时,由你手动决定时间;停用状态会保留。', 'Install through DSH’s native plugin manager. Choose when to restart if needed; disabled status is preserved.')), h('p', { className: 'w-muted' }, t('来源:npm 官方 registry,网络不可达时回退 npmmirror。安装前再次核对版本与完整性。', 'Source: official npm registry, with npmmirror fallback if unreachable. Version and integrity are checked again before installation.')), h('div', { className: 'w-toolbar' }, h(W.Button, { onClick: () => setDialog(null) }, t('取消', 'Cancel')), h(W.Button, { variant: 'primary', onClick: install }, t('确认安装此版本', 'Install this version')))))
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
function Overlay() {
|
|
348
|
+
useSnapshot();
|
|
349
|
+
const row = activeName && originalRow(activeName);
|
|
350
|
+
if (!row) return null;
|
|
351
|
+
const content = h('div', { className: 'dshw' }, h('style', null, W.styles), h(W.Dialog, { title: (language() === 'en' ? row.labelEn : row.label) + t(' · 更新设置', ' · Update settings'), closeLabel: t('关闭更新设置', 'Close update settings'), onClose: () => { activeName = null; notify(); } }, h(Panel, { key: activeName, name: activeName })));
|
|
352
|
+
const settings = document.querySelector('[data-shortcut-modal="settings"]');
|
|
353
|
+
// Keep the dialog inside the native settings focus boundary when its
|
|
354
|
+
// per-plugin entry was opened there. The shared fiber survives HMR.
|
|
355
|
+
return settings ? require('react-dom').createPortal(content, settings) : content;
|
|
356
|
+
}
|
|
357
|
+
function Reminder() {
|
|
358
|
+
useSnapshot();
|
|
359
|
+
const row = state?.rows.find(row => { const prefs = preferences(row.name); return row.available && !row.updating && !row.bundledBy && prefs.checkEnabled && prefs.deferredUntil <= Date.now() && prefs.ignoredVersion !== row.release.version && !notified.has(row.name + '@' + row.release.version); });
|
|
360
|
+
if (!row) return null;
|
|
361
|
+
let Toast; try { Toast = require('@deepseek-ai/dsh-client-ui-primitives').Toast; } catch { return null; }
|
|
362
|
+
if (!Toast) return null;
|
|
363
|
+
const key = row.name + '@' + row.release.version;
|
|
364
|
+
return h(Toast, { key, text: (language() === 'en' ? row.labelEn : row.label) + t('有新版本,可在它的页面或设置中查看。', ' has an update. Review it on its page or in settings.'), icon: h(W.Icon, { name: 'refresh', size: 18 }), holdMs: 7000, onDone: () => { notified.add(key); notify(); } });
|
|
365
|
+
}
|
|
366
|
+
function register() {
|
|
367
|
+
if (!alive) return;
|
|
368
|
+
if (!registered) {
|
|
369
|
+
registered = true; pool.clients.add(client);
|
|
370
|
+
ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'stardustlc.plugin-update-dialog' }, Overlay));
|
|
371
|
+
ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'stardustlc.plugin-update-reminder' }, Reminder));
|
|
372
|
+
notify();
|
|
373
|
+
}
|
|
374
|
+
for (const row of state.rows) {
|
|
375
|
+
const hasWorkbench = [...shared.refs.values()].some(participant => participant.name === row.name && participant.hasWorkbench);
|
|
376
|
+
if (hasWorkbench) { if (sections.has(row.name)) { void sections.get(row.name).dispose(); sections.delete(row.name); } continue; }
|
|
377
|
+
if (sections.has(row.name)) continue;
|
|
378
|
+
const label = () => (language() === 'en' ? row.labelEn : row.label) + t(' · 更新', ' · Updates');
|
|
379
|
+
const fiber = ctx.plugin({ name: 'plugin-update-settings-' + row.name, inject: ['slots', 'locale'], apply(section) {
|
|
380
|
+
section.slots.inject('settings.section', () => section.slots.register({ name: 'settings.section', id: row.name + '.updates', label, order: 89 }, () => h(Panel, { name: row.name })));
|
|
381
|
+
} });
|
|
382
|
+
sections.set(row.name, fiber);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
async function poll() {
|
|
386
|
+
if (inPoll || !alive || !shared.refs.size) return;
|
|
387
|
+
inPoll = true;
|
|
388
|
+
try { await refresh(); register(); await heartbeat(); }
|
|
389
|
+
catch { /* Owner-only controls stay absent for guests and unsupported hosts. */ }
|
|
390
|
+
finally { inPoll = false; }
|
|
391
|
+
}
|
|
392
|
+
const timer = setInterval(() => { void poll(); }, 15000), first = setTimeout(() => { void poll(); }, 100);
|
|
393
|
+
const client = { t, Button: W.Button, has: name => registered && !!originalRow(name), row: rowFor, open(name) { activeName = name; notify(); }, dispose() {
|
|
394
|
+
close(); alive = false; clearTimeout(first); clearInterval(timer); pool.clients.delete(client);
|
|
395
|
+
pool.listeners.delete(guardChanged); listeners.clear();
|
|
396
|
+
for (const event of ['pointerdown', 'keydown', 'input']) document.removeEventListener(event, interaction, true);
|
|
397
|
+
window.removeEventListener('pagehide', close);
|
|
398
|
+
for (const controller of requests) controller.abort();
|
|
399
|
+
for (const listener of pool.viewListeners) listener();
|
|
400
|
+
} };
|
|
401
|
+
return client;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/* STARDUSTLC_UPDATE_CLIENT_BODY_START */
|
|
405
|
+
window.__ModuleLoader__.load(__stardustUpdatesWrap({id:"dsh-sql",factory:require=>({apply(){}})},{"name":"dsh-sql","version":"0.1.0","hasWorkbench":false}));
|
|
406
|
+
|
|
407
|
+
/* STARDUSTLC_UPDATE_CLIENT_BODY_END */
|
|
408
|
+
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-sql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "连接 SQLite、MySQL 或 PostgreSQL,查询数据并检查数据库结构。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"default": "./lib/index.js"
|
|
12
12
|
},
|
|
13
13
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
14
|
-
"./package.json": "./package.json"
|
|
14
|
+
"./package.json": "./package.json",
|
|
15
|
+
"./client": "./lib/update-client.js"
|
|
15
16
|
},
|
|
16
17
|
"files": [
|
|
17
18
|
"lib",
|
|
@@ -22,7 +23,7 @@
|
|
|
22
23
|
"CHANGELOG.md"
|
|
23
24
|
],
|
|
24
25
|
"scripts": {
|
|
25
|
-
"build": "tsc -p tsconfig.json",
|
|
26
|
+
"build": "tsc -p tsconfig.json && node scripts/build-updates.mjs",
|
|
26
27
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
27
28
|
"test": "pnpm run build && node --test \"test/*.test.mjs\"",
|
|
28
29
|
"prepublishOnly": "pnpm run build"
|
|
@@ -30,6 +31,14 @@
|
|
|
30
31
|
"dsh": {
|
|
31
32
|
"bundle": {
|
|
32
33
|
"patch": "./cordis.patch.yml"
|
|
34
|
+
},
|
|
35
|
+
"client": {
|
|
36
|
+
"platform": "web",
|
|
37
|
+
"inject": [
|
|
38
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
39
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
40
|
+
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
41
|
+
]
|
|
33
42
|
}
|
|
34
43
|
},
|
|
35
44
|
"keywords": [
|
|
@@ -45,11 +54,13 @@
|
|
|
45
54
|
],
|
|
46
55
|
"license": "MIT",
|
|
47
56
|
"engines": {
|
|
48
|
-
"node": "^22.19.0 || >=24.0.0"
|
|
57
|
+
"node": "^22.19.0 || >=24.0.0",
|
|
58
|
+
"dsh": ">=0.2.1-rc.3"
|
|
49
59
|
},
|
|
50
60
|
"dependencies": {
|
|
51
61
|
"mysql2": "^3.11.0",
|
|
52
|
-
"pg": "^8.13.0"
|
|
62
|
+
"pg": "^8.13.0",
|
|
63
|
+
"@stardustlc/dsh-updates": "0.1.0"
|
|
53
64
|
},
|
|
54
65
|
"devDependencies": {
|
|
55
66
|
"@types/node": "^24.0.0",
|