koishi-plugin-argus 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/blur.d.ts +11 -0
- package/lib/cache.d.ts +27 -0
- package/lib/commands.d.ts +5 -0
- package/lib/index.cjs +624 -0
- package/lib/index.d.ts +26 -0
- package/lib/index.mjs +600 -0
- package/lib/server.d.ts +60 -0
- package/lib/types.d.ts +75 -0
- package/package.json +89 -0
- package/readme.md +70 -0
package/lib/blur.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type BlurMode = 'gaussian' | 'fast';
|
|
2
|
+
export interface BlurOptions {
|
|
3
|
+
/** 模糊半径,越大越糊。 */
|
|
4
|
+
radius: number;
|
|
5
|
+
/** 模糊算法。`gaussian` 质量好但慢;`fast` 用 jimp.blur,速度快。 */
|
|
6
|
+
mode?: BlurMode;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* 加载任意常见格式(png/jpg/webp 等)的图片,应用模糊后输出 PNG buffer。
|
|
10
|
+
*/
|
|
11
|
+
export declare function blurImage(input: Buffer, options: BlurOptions): Promise<Buffer>;
|
package/lib/cache.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PeekBusyFrame } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* 缓存一次 peek 的响应(图片 buffer 或 busy 状态)。
|
|
4
|
+
* 在 cacheDuration 内对同一 (client, display) 的命令调用直接复用。
|
|
5
|
+
*/
|
|
6
|
+
export interface CachedPeek {
|
|
7
|
+
cachedAt: number;
|
|
8
|
+
expiresAt: number;
|
|
9
|
+
/** 已经过模糊处理的最终 PNG buffer,busy 时为空。 */
|
|
10
|
+
image?: Buffer;
|
|
11
|
+
/** busy 状态:客户端在玩游戏 / 全屏。 */
|
|
12
|
+
busy?: PeekBusyFrame;
|
|
13
|
+
}
|
|
14
|
+
export declare class PeekCache {
|
|
15
|
+
private duration;
|
|
16
|
+
private store;
|
|
17
|
+
private timers;
|
|
18
|
+
constructor(duration: number);
|
|
19
|
+
setDuration(duration: number): void;
|
|
20
|
+
/** key 形如 `client::display`。display 缺省用 'default'。 */
|
|
21
|
+
static key(client: string, display?: number | string): string;
|
|
22
|
+
get(key: string): CachedPeek | undefined;
|
|
23
|
+
set(key: string, entry: Omit<CachedPeek, 'cachedAt' | 'expiresAt'>): void;
|
|
24
|
+
delete(key: string): void;
|
|
25
|
+
clear(): void;
|
|
26
|
+
}
|
|
27
|
+
export declare function formatRemaining(ms: number): string;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Context } from 'koishi';
|
|
2
|
+
import type { ArgusServer } from './server';
|
|
3
|
+
import type { Config } from '.';
|
|
4
|
+
import { PeekCache } from './cache';
|
|
5
|
+
export declare function applyCommands(ctx: Context, server: ArgusServer, config: Config, cache: PeekCache): import("koishi").Command<never, never, [string, ...string[]], import("koishi").Extend<import("koishi").Extend<import("koishi").Extend<import("koishi").Extend<{}, "display", number>, "blur", number>, "list", boolean>, "force", boolean>>;
|
package/lib/index.cjs
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
6
|
+
var __commonJS = (cb, mod) => function __require() {
|
|
7
|
+
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
8
|
+
};
|
|
9
|
+
var __export = (target, all) => {
|
|
10
|
+
for (var name2 in all)
|
|
11
|
+
__defProp(target, name2, { get: all[name2], enumerable: true });
|
|
12
|
+
};
|
|
13
|
+
var __copyProps = (to, from, except, desc) => {
|
|
14
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
15
|
+
for (let key of __getOwnPropNames(from))
|
|
16
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
17
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
18
|
+
}
|
|
19
|
+
return to;
|
|
20
|
+
};
|
|
21
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
22
|
+
|
|
23
|
+
// src/locales/zh-CN.schema.yml
|
|
24
|
+
var require_zh_CN_schema = __commonJS({
|
|
25
|
+
"src/locales/zh-CN.schema.yml"(exports2, module2) {
|
|
26
|
+
module2.exports = { $desc: "Argus 配置", $inner: { path: "WebSocket 服务挂载路径,必须以 / 开头。", token: "客户端鉴权 token;为空时所有连接都会被拒绝。", commandName: "主命令名(例如 peek、spy、look)。", blur: "默认模糊半径,越大越糊(0 = 不模糊)。", blurMode: { $desc: "模糊算法", $inner: ["高斯模糊(质量好,但慢)", "快速模糊(jimp.blur,速度快)"] }, minBlur: "命令里 -b 临时调小模糊时不可低于此值,防止裸奔。", maxImageBytes: "单张截图大小上限(字节)。", timeout: "等待客户端响应的超时(毫秒)。", cacheDuration: "截图缓存时长(毫秒)。在此期间内同一客户端 + 显示器的反复调用会直接返回缓存的图。设为 0 关闭缓存。默认 5 分钟。", registerAlias: "是否给每个连进来的客户端自动注册同名命令作为别名。", authority: "命令所需的最低权限等级。", forceAuthority: "使用 -f / --force 绕过缓存所需的最低权限等级。" } };
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// src/locales/en-US.schema.yml
|
|
31
|
+
var require_en_US_schema = __commonJS({
|
|
32
|
+
"src/locales/en-US.schema.yml"(exports2, module2) {
|
|
33
|
+
module2.exports = { $desc: "Argus configuration", $inner: { path: "WebSocket mount path; must start with /.", token: "Auth token for clients; if empty all connections are rejected.", commandName: "Top-level command name (e.g. peek, spy, look).", blur: "Default blur radius; larger means blurrier (0 disables blur).", blurMode: { $desc: "Blur algorithm", $inner: ["Gaussian blur (better quality, slower)", "Fast blur (jimp.blur, faster)"] }, minBlur: "Lower bound for the temporary -b override, to prevent unblurred captures.", maxImageBytes: "Max screenshot size in bytes.", timeout: "Timeout (ms) waiting for a client response.", cacheDuration: "Screenshot cache duration in ms. Repeated calls within this window for the same client+display reuse the cached image. Set to 0 to disable. Default 5 minutes.", registerAlias: "Whether to register each connected client's name as a command alias.", authority: "Minimum authority level required to run the command.", forceAuthority: "Minimum authority level required to use -f / --force to bypass the cache." } };
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// src/locales/zh-CN.yml
|
|
38
|
+
var require_zh_CN = __commonJS({
|
|
39
|
+
"src/locales/zh-CN.yml"(exports2, module2) {
|
|
40
|
+
module2.exports = { commands: { peek: { description: "偷窥已连接客户端的电脑屏幕。", usage: "peek [name] [-d display] [-b blur] [-f] [--list]", examples: "peek\npeek dingyi\npeek dingyi -d 1\npeek dingyi -b 80\npeek dingyi -f\npeek --list", options: { display: "指定客户端的显示器编号。", blur: "临时调整模糊半径,不能低于配置中的 minBlur。", list: "列出当前在线的客户端。", force: "绕过缓存强制重新截图(需要 forceAuthority)。" }, messages: { "no-clients": "当前没有任何客户端在线。", "multiple-clients": "当前有多个客户端在线,请指定名称:{0}", "client-offline": "客户端「{0}」未在线。", timeout: "客户端「{0}」响应超时。", "image-too-large": "截图体积超过限制。", failed: "截图失败:{0}", "list-header": "当前在线客户端 ({0}):", busy: "客户端「{0}」正忙:{1}", "cache-note": "(缓存,剩余 {0})" } } } };
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// src/locales/en-US.yml
|
|
45
|
+
var require_en_US = __commonJS({
|
|
46
|
+
"src/locales/en-US.yml"(exports2, module2) {
|
|
47
|
+
module2.exports = { commands: { peek: { description: "Peek at a connected client's screen.", usage: "peek [name] [-d display] [-b blur] [-f] [--list]", examples: "peek\npeek dingyi\npeek dingyi -d 1\npeek dingyi -b 80\npeek dingyi -f\npeek --list", options: { display: "Display index on the client.", blur: "Override blur radius (must be >= configured minBlur).", list: "List currently connected clients.", force: "Bypass the cache (requires forceAuthority)." }, messages: { "no-clients": "No clients are connected.", "multiple-clients": "Multiple clients online, please specify: {0}", "client-offline": 'Client "{0}" is not connected.', timeout: 'Client "{0}" timed out.', "image-too-large": "Screenshot size exceeded the limit.", failed: "Capture failed: {0}", "list-header": "Connected clients ({0}):", busy: 'Client "{0}" is busy: {1}', "cache-note": "(cached, {0} left)" } } } };
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// src/index.ts
|
|
52
|
+
var index_exports = {};
|
|
53
|
+
__export(index_exports, {
|
|
54
|
+
Config: () => Config,
|
|
55
|
+
apply: () => apply,
|
|
56
|
+
inject: () => inject,
|
|
57
|
+
name: () => name
|
|
58
|
+
});
|
|
59
|
+
module.exports = __toCommonJS(index_exports);
|
|
60
|
+
var import_koishi2 = require("koishi");
|
|
61
|
+
|
|
62
|
+
// src/server.ts
|
|
63
|
+
var HEARTBEAT_INTERVAL = 3e4;
|
|
64
|
+
var HEARTBEAT_TIMEOUT = HEARTBEAT_INTERVAL * 3;
|
|
65
|
+
var ArgusServer = class {
|
|
66
|
+
constructor(ctx, config) {
|
|
67
|
+
this.ctx = ctx;
|
|
68
|
+
this.config = config;
|
|
69
|
+
this.mount();
|
|
70
|
+
}
|
|
71
|
+
static {
|
|
72
|
+
__name(this, "ArgusServer");
|
|
73
|
+
}
|
|
74
|
+
/** name → client */
|
|
75
|
+
clients = /* @__PURE__ */ new Map();
|
|
76
|
+
listClients() {
|
|
77
|
+
return [...this.clients.values()];
|
|
78
|
+
}
|
|
79
|
+
getClient(name2) {
|
|
80
|
+
return this.clients.get(name2);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 派发一次截图请求并等待结果。可能返回图片或“客户端忙”。
|
|
84
|
+
*/
|
|
85
|
+
async peek(name2, options = {}) {
|
|
86
|
+
const client = this.clients.get(name2);
|
|
87
|
+
if (!client) throw new Error(`client_offline:${name2}`);
|
|
88
|
+
const id = randomId();
|
|
89
|
+
const frame = {
|
|
90
|
+
type: "peek",
|
|
91
|
+
id,
|
|
92
|
+
display: options.display
|
|
93
|
+
};
|
|
94
|
+
return await new Promise((resolve, reject) => {
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
client.pending.delete(id);
|
|
97
|
+
reject(new Error("timeout"));
|
|
98
|
+
}, this.config.timeout);
|
|
99
|
+
client.pending.set(id, { resolve, reject, timer });
|
|
100
|
+
try {
|
|
101
|
+
this.send(client.socket, frame);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
client.pending.delete(id);
|
|
105
|
+
reject(
|
|
106
|
+
err instanceof Error ? err : new Error(String(err))
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
mount() {
|
|
112
|
+
const layer = this.ctx.server.ws(
|
|
113
|
+
this.config.path,
|
|
114
|
+
(socket, req) => this.handleConnection(socket, req)
|
|
115
|
+
);
|
|
116
|
+
this.ctx.on("dispose", () => {
|
|
117
|
+
for (const client of this.clients.values()) {
|
|
118
|
+
this.cleanup(client, "plugin_dispose");
|
|
119
|
+
}
|
|
120
|
+
this.clients.clear();
|
|
121
|
+
layer.close();
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
handleConnection(socket, _req) {
|
|
125
|
+
let client;
|
|
126
|
+
const helloTimer = setTimeout(() => {
|
|
127
|
+
if (!client) {
|
|
128
|
+
this.send(socket, {
|
|
129
|
+
type: "hello_ack",
|
|
130
|
+
ok: false,
|
|
131
|
+
error: "hello_timeout"
|
|
132
|
+
});
|
|
133
|
+
socket.close(4002, "hello_timeout");
|
|
134
|
+
}
|
|
135
|
+
}, 1e4);
|
|
136
|
+
socket.on("message", (raw, isBinary) => {
|
|
137
|
+
if (isBinary) return;
|
|
138
|
+
const text = typeof raw === "string" ? raw : raw.toString("utf8");
|
|
139
|
+
if (text.length > 4 * 1024 * 1024) {
|
|
140
|
+
socket.close(1009, "message_too_large");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
let frame;
|
|
144
|
+
try {
|
|
145
|
+
frame = JSON.parse(text);
|
|
146
|
+
} catch {
|
|
147
|
+
socket.close(1003, "invalid_json");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (!client) {
|
|
151
|
+
if (frame.type !== "hello") {
|
|
152
|
+
socket.close(4003, "expect_hello");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
clearTimeout(helloTimer);
|
|
156
|
+
client = this.handleHello(socket, frame);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
client.lastSeen = Date.now();
|
|
160
|
+
this.handleAuthedFrame(client, frame);
|
|
161
|
+
});
|
|
162
|
+
socket.on("close", () => {
|
|
163
|
+
clearTimeout(helloTimer);
|
|
164
|
+
if (client) this.cleanup(client, "socket_close");
|
|
165
|
+
});
|
|
166
|
+
socket.on("error", (err) => {
|
|
167
|
+
this.ctx.logger.warn("argus socket error: %s", err.message);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
handleHello(socket, frame) {
|
|
171
|
+
if (!frame.token || frame.token !== this.config.token) {
|
|
172
|
+
this.send(socket, {
|
|
173
|
+
type: "hello_ack",
|
|
174
|
+
ok: false,
|
|
175
|
+
error: "auth_failed"
|
|
176
|
+
});
|
|
177
|
+
socket.close(4001, "auth_failed");
|
|
178
|
+
return void 0;
|
|
179
|
+
}
|
|
180
|
+
const name2 = (frame.name || "").trim();
|
|
181
|
+
if (!name2 || !/^[a-zA-Z0-9_\-.]{1,32}$/.test(name2)) {
|
|
182
|
+
this.send(socket, {
|
|
183
|
+
type: "hello_ack",
|
|
184
|
+
ok: false,
|
|
185
|
+
error: "invalid_name"
|
|
186
|
+
});
|
|
187
|
+
socket.close(4004, "invalid_name");
|
|
188
|
+
return void 0;
|
|
189
|
+
}
|
|
190
|
+
const old = this.clients.get(name2);
|
|
191
|
+
if (old) this.cleanup(old, "replaced");
|
|
192
|
+
const client = {
|
|
193
|
+
name: name2,
|
|
194
|
+
socket,
|
|
195
|
+
version: frame.version,
|
|
196
|
+
displays: frame.displays ?? [],
|
|
197
|
+
defaultDisplay: frame.defaultDisplay,
|
|
198
|
+
connectedAt: Date.now(),
|
|
199
|
+
pending: /* @__PURE__ */ new Map(),
|
|
200
|
+
lastSeen: Date.now()
|
|
201
|
+
};
|
|
202
|
+
client.heartbeatTimer = setInterval(() => {
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
if (now - client.lastSeen > HEARTBEAT_TIMEOUT) {
|
|
205
|
+
this.ctx.logger.info(
|
|
206
|
+
"argus client %s heartbeat lost",
|
|
207
|
+
client.name
|
|
208
|
+
);
|
|
209
|
+
socket.close(4005, "heartbeat_lost");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
this.send(socket, { type: "ping", t: now });
|
|
213
|
+
}, HEARTBEAT_INTERVAL);
|
|
214
|
+
this.clients.set(name2, client);
|
|
215
|
+
this.send(socket, { type: "hello_ack", ok: true });
|
|
216
|
+
this.ctx.logger.info(
|
|
217
|
+
"argus client connected: %s (displays=%d)",
|
|
218
|
+
name2,
|
|
219
|
+
client.displays.length
|
|
220
|
+
);
|
|
221
|
+
this.config.onClientChange?.({ type: "connect", name: name2 });
|
|
222
|
+
return client;
|
|
223
|
+
}
|
|
224
|
+
handleAuthedFrame(client, frame) {
|
|
225
|
+
switch (frame.type) {
|
|
226
|
+
case "peek_result": {
|
|
227
|
+
const pending = client.pending.get(frame.id);
|
|
228
|
+
if (!pending) return;
|
|
229
|
+
clearTimeout(pending.timer);
|
|
230
|
+
client.pending.delete(frame.id);
|
|
231
|
+
const size = (frame.image?.length ?? 0) * 0.75;
|
|
232
|
+
if (size > this.config.maxImageBytes) {
|
|
233
|
+
pending.reject(new Error("image_too_large"));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
pending.resolve({ kind: "image", frame });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
case "peek_busy": {
|
|
240
|
+
const pending = client.pending.get(frame.id);
|
|
241
|
+
if (!pending) return;
|
|
242
|
+
clearTimeout(pending.timer);
|
|
243
|
+
client.pending.delete(frame.id);
|
|
244
|
+
pending.resolve({ kind: "busy", frame });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
case "peek_error": {
|
|
248
|
+
const pending = client.pending.get(frame.id);
|
|
249
|
+
if (!pending) return;
|
|
250
|
+
clearTimeout(pending.timer);
|
|
251
|
+
client.pending.delete(frame.id);
|
|
252
|
+
pending.reject(new Error(frame.error || "client_error"));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
case "ping":
|
|
256
|
+
this.send(client.socket, { type: "pong", t: frame.t });
|
|
257
|
+
return;
|
|
258
|
+
case "pong":
|
|
259
|
+
return;
|
|
260
|
+
case "bye":
|
|
261
|
+
client.socket.close(1e3, frame.reason || "bye");
|
|
262
|
+
return;
|
|
263
|
+
case "hello":
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
cleanup(client, reason) {
|
|
268
|
+
if (client.heartbeatTimer) clearInterval(client.heartbeatTimer);
|
|
269
|
+
for (const pending of client.pending.values()) {
|
|
270
|
+
clearTimeout(pending.timer);
|
|
271
|
+
pending.reject(new Error(`disconnected:${reason}`));
|
|
272
|
+
}
|
|
273
|
+
client.pending.clear();
|
|
274
|
+
if (this.clients.get(client.name) === client) {
|
|
275
|
+
this.clients.delete(client.name);
|
|
276
|
+
this.ctx.logger.info(
|
|
277
|
+
"argus client disconnected: %s (%s)",
|
|
278
|
+
client.name,
|
|
279
|
+
reason
|
|
280
|
+
);
|
|
281
|
+
this.config.onClientChange?.({
|
|
282
|
+
type: "disconnect",
|
|
283
|
+
name: client.name
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
if (client.socket.readyState === client.socket.OPEN || client.socket.readyState === client.socket.CONNECTING) {
|
|
288
|
+
client.socket.close(1e3, reason);
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
send(socket, frame) {
|
|
294
|
+
if (socket.readyState !== socket.OPEN) return;
|
|
295
|
+
socket.send(JSON.stringify(frame));
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
function randomId() {
|
|
299
|
+
return Math.random().toString(36).slice(2, 10);
|
|
300
|
+
}
|
|
301
|
+
__name(randomId, "randomId");
|
|
302
|
+
|
|
303
|
+
// src/commands.ts
|
|
304
|
+
var import_koishi = require("koishi");
|
|
305
|
+
|
|
306
|
+
// src/blur.ts
|
|
307
|
+
var import_core = require("@jimp/core");
|
|
308
|
+
var import_jimp = require("jimp");
|
|
309
|
+
var Jimp = (0, import_core.createJimp)({
|
|
310
|
+
formats: [...import_jimp.defaultFormats],
|
|
311
|
+
plugins: import_jimp.defaultPlugins
|
|
312
|
+
});
|
|
313
|
+
async function blurImage(input, options) {
|
|
314
|
+
const radius = clamp(Math.round(options.radius), 0, 200);
|
|
315
|
+
const image = await Jimp.read(input);
|
|
316
|
+
if (radius > 0) {
|
|
317
|
+
if (options.mode === "gaussian") {
|
|
318
|
+
const r = Math.max(1, Math.min(10, Math.round(radius / 20)));
|
|
319
|
+
image.gaussian(r);
|
|
320
|
+
} else {
|
|
321
|
+
const r = Math.max(1, Math.min(100, radius));
|
|
322
|
+
image.blur(r);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return await image.getBuffer("image/png");
|
|
326
|
+
}
|
|
327
|
+
__name(blurImage, "blurImage");
|
|
328
|
+
function clamp(v, min, max) {
|
|
329
|
+
return Math.max(min, Math.min(max, v));
|
|
330
|
+
}
|
|
331
|
+
__name(clamp, "clamp");
|
|
332
|
+
|
|
333
|
+
// src/cache.ts
|
|
334
|
+
var PeekCache = class {
|
|
335
|
+
constructor(duration) {
|
|
336
|
+
this.duration = duration;
|
|
337
|
+
}
|
|
338
|
+
static {
|
|
339
|
+
__name(this, "PeekCache");
|
|
340
|
+
}
|
|
341
|
+
store = /* @__PURE__ */ new Map();
|
|
342
|
+
timers = /* @__PURE__ */ new Map();
|
|
343
|
+
setDuration(duration) {
|
|
344
|
+
this.duration = duration;
|
|
345
|
+
}
|
|
346
|
+
/** key 形如 `client::display`。display 缺省用 'default'。 */
|
|
347
|
+
static key(client, display) {
|
|
348
|
+
return `${client}::${display ?? "default"}`;
|
|
349
|
+
}
|
|
350
|
+
get(key) {
|
|
351
|
+
const entry = this.store.get(key);
|
|
352
|
+
if (!entry) return void 0;
|
|
353
|
+
if (Date.now() >= entry.expiresAt) {
|
|
354
|
+
this.delete(key);
|
|
355
|
+
return void 0;
|
|
356
|
+
}
|
|
357
|
+
return entry;
|
|
358
|
+
}
|
|
359
|
+
set(key, entry) {
|
|
360
|
+
if (this.duration <= 0) return;
|
|
361
|
+
this.delete(key);
|
|
362
|
+
const cachedAt = Date.now();
|
|
363
|
+
const full = {
|
|
364
|
+
...entry,
|
|
365
|
+
cachedAt,
|
|
366
|
+
expiresAt: cachedAt + this.duration
|
|
367
|
+
};
|
|
368
|
+
this.store.set(key, full);
|
|
369
|
+
const timer = setTimeout(() => this.delete(key), this.duration);
|
|
370
|
+
this.timers.set(key, timer);
|
|
371
|
+
}
|
|
372
|
+
delete(key) {
|
|
373
|
+
this.store.delete(key);
|
|
374
|
+
const timer = this.timers.get(key);
|
|
375
|
+
if (timer) {
|
|
376
|
+
clearTimeout(timer);
|
|
377
|
+
this.timers.delete(key);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
clear() {
|
|
381
|
+
for (const timer of this.timers.values()) clearTimeout(timer);
|
|
382
|
+
this.timers.clear();
|
|
383
|
+
this.store.clear();
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
function formatRemaining(ms) {
|
|
387
|
+
const sec = Math.max(0, Math.ceil(ms / 1e3));
|
|
388
|
+
if (sec < 60) return `${sec}s`;
|
|
389
|
+
const minutes = Math.floor(sec / 60);
|
|
390
|
+
const seconds = sec % 60;
|
|
391
|
+
if (seconds === 0) return `${minutes}m`;
|
|
392
|
+
return `${minutes}m${seconds}s`;
|
|
393
|
+
}
|
|
394
|
+
__name(formatRemaining, "formatRemaining");
|
|
395
|
+
|
|
396
|
+
// src/commands.ts
|
|
397
|
+
function applyCommands(ctx, server, config, cache) {
|
|
398
|
+
const cmd = ctx.command(
|
|
399
|
+
`${config.commandName} [name:string]`,
|
|
400
|
+
{ authority: config.authority }
|
|
401
|
+
).option("display", "-d <id:number>").option("blur", "-b <radius:number>").option("list", "-l, --list").option("force", "-f, --force", { authority: config.forceAuthority }).action(async ({ session, options }, name2) => {
|
|
402
|
+
if (!session) return;
|
|
403
|
+
const opts = options;
|
|
404
|
+
if (opts.list) {
|
|
405
|
+
return formatClientList(server, session);
|
|
406
|
+
}
|
|
407
|
+
const clients = server.listClients();
|
|
408
|
+
if (clients.length === 0) {
|
|
409
|
+
return session.text(".no-clients");
|
|
410
|
+
}
|
|
411
|
+
let target = name2?.trim();
|
|
412
|
+
if (!target) {
|
|
413
|
+
if (clients.length === 1) {
|
|
414
|
+
target = clients[0].name;
|
|
415
|
+
} else {
|
|
416
|
+
return session.text(".multiple-clients", [
|
|
417
|
+
clients.map((c) => c.name).join(", ")
|
|
418
|
+
]);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const client = server.getClient(target);
|
|
422
|
+
if (!client) {
|
|
423
|
+
return session.text(".client-offline", [target]);
|
|
424
|
+
}
|
|
425
|
+
const radius = clamp2(
|
|
426
|
+
opts.blur ?? config.blur,
|
|
427
|
+
config.minBlur,
|
|
428
|
+
200
|
|
429
|
+
);
|
|
430
|
+
const cacheKey = PeekCache.key(client.name, opts.display);
|
|
431
|
+
const cached = !opts.force ? cache.get(cacheKey) : void 0;
|
|
432
|
+
const cacheUsable = cached && (opts.blur === void 0 || opts.blur === config.blur);
|
|
433
|
+
if (cacheUsable && cached) {
|
|
434
|
+
if (cached.busy) {
|
|
435
|
+
return formatBusy(session, client.name, cached.busy, cached);
|
|
436
|
+
}
|
|
437
|
+
if (cached.image) {
|
|
438
|
+
return [
|
|
439
|
+
import_koishi.h.image(cached.image, "image/png"),
|
|
440
|
+
formatCacheNote(session, cached)
|
|
441
|
+
];
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
try {
|
|
445
|
+
const response = await server.peek(client.name, {
|
|
446
|
+
display: opts.display
|
|
447
|
+
});
|
|
448
|
+
if (response.kind === "busy") {
|
|
449
|
+
cache.set(cacheKey, { busy: response.frame });
|
|
450
|
+
return formatBusy(session, client.name, response.frame);
|
|
451
|
+
}
|
|
452
|
+
const buffer = Buffer.from(response.frame.image, "base64");
|
|
453
|
+
const output = await blurImage(buffer, {
|
|
454
|
+
radius,
|
|
455
|
+
mode: config.blurMode
|
|
456
|
+
});
|
|
457
|
+
if (opts.blur === void 0 || opts.blur === config.blur) {
|
|
458
|
+
cache.set(cacheKey, { image: output });
|
|
459
|
+
}
|
|
460
|
+
return import_koishi.h.image(output, "image/png");
|
|
461
|
+
} catch (err) {
|
|
462
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
463
|
+
ctx.logger.warn(
|
|
464
|
+
"argus peek failed for %s: %s",
|
|
465
|
+
client.name,
|
|
466
|
+
message
|
|
467
|
+
);
|
|
468
|
+
if (message === "timeout") {
|
|
469
|
+
return session.text(".timeout", [client.name]);
|
|
470
|
+
}
|
|
471
|
+
if (message === "image_too_large") {
|
|
472
|
+
return session.text(".image-too-large");
|
|
473
|
+
}
|
|
474
|
+
if (message.startsWith("disconnected:")) {
|
|
475
|
+
return session.text(".client-offline", [client.name]);
|
|
476
|
+
}
|
|
477
|
+
return session.text(".failed", [message]);
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
if (config.registerAlias) {
|
|
481
|
+
const disposers = /* @__PURE__ */ new Map();
|
|
482
|
+
const register = /* @__PURE__ */ __name((name2) => {
|
|
483
|
+
if (!isSafeAlias(name2)) return;
|
|
484
|
+
if (disposers.has(name2)) return;
|
|
485
|
+
if (ctx.$commander.get(name2)) return;
|
|
486
|
+
const sub = ctx.command(`${name2}`, { authority: config.authority }).option("display", "-d <id:number>").option("blur", "-b <radius:number>").option("force", "-f, --force", { authority: config.forceAuthority }).action(async ({ session, options }) => {
|
|
487
|
+
if (!session) return;
|
|
488
|
+
const opts = options;
|
|
489
|
+
const parts = [config.commandName, name2];
|
|
490
|
+
if (opts.display !== void 0) {
|
|
491
|
+
parts.push("-d", String(opts.display));
|
|
492
|
+
}
|
|
493
|
+
if (opts.blur !== void 0) {
|
|
494
|
+
parts.push("-b", String(opts.blur));
|
|
495
|
+
}
|
|
496
|
+
if (opts.force) parts.push("-f");
|
|
497
|
+
return await session.execute(parts.join(" "));
|
|
498
|
+
});
|
|
499
|
+
disposers.set(name2, () => sub.dispose());
|
|
500
|
+
}, "register");
|
|
501
|
+
const unregister = /* @__PURE__ */ __name((name2) => {
|
|
502
|
+
const dispose = disposers.get(name2);
|
|
503
|
+
if (dispose) {
|
|
504
|
+
dispose();
|
|
505
|
+
disposers.delete(name2);
|
|
506
|
+
}
|
|
507
|
+
}, "unregister");
|
|
508
|
+
for (const client of server.listClients()) register(client.name);
|
|
509
|
+
ctx.on("argus/client-connect", register);
|
|
510
|
+
ctx.on("argus/client-disconnect", (name2) => {
|
|
511
|
+
unregister(name2);
|
|
512
|
+
for (const display of [void 0, ...range(0, 16)]) {
|
|
513
|
+
cache.delete(PeekCache.key(name2, display));
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
ctx.on("dispose", () => {
|
|
517
|
+
for (const dispose of disposers.values()) dispose();
|
|
518
|
+
disposers.clear();
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
return cmd;
|
|
522
|
+
}
|
|
523
|
+
__name(applyCommands, "applyCommands");
|
|
524
|
+
function formatClientList(server, session) {
|
|
525
|
+
const clients = server.listClients();
|
|
526
|
+
if (clients.length === 0) return session.text(".no-clients");
|
|
527
|
+
const lines = clients.map((c) => {
|
|
528
|
+
const displays = c.displays.length ? c.displays.map((d) => {
|
|
529
|
+
const label = d.name ?? `display-${d.id}`;
|
|
530
|
+
const size = d.width && d.height ? ` ${d.width}x${d.height}` : "";
|
|
531
|
+
const star = d.id === c.defaultDisplay ? "*" : "";
|
|
532
|
+
return `${star}${d.id}:${label}${size}`;
|
|
533
|
+
}).join(", ") : "-";
|
|
534
|
+
return `· ${c.name} [${displays}]`;
|
|
535
|
+
});
|
|
536
|
+
return session.text(".list-header", [clients.length]) + "\n" + lines.join("\n");
|
|
537
|
+
}
|
|
538
|
+
__name(formatClientList, "formatClientList");
|
|
539
|
+
function formatBusy(session, clientName, busy, cached) {
|
|
540
|
+
const app = busy.app || busy.title || "unknown app";
|
|
541
|
+
const note = cached ? " " + session.text(".cache-note", [
|
|
542
|
+
formatRemaining(cached.expiresAt - Date.now())
|
|
543
|
+
]) : "";
|
|
544
|
+
return session.text(".busy", [clientName, app]) + note;
|
|
545
|
+
}
|
|
546
|
+
__name(formatBusy, "formatBusy");
|
|
547
|
+
function formatCacheNote(session, cached) {
|
|
548
|
+
return session.text(".cache-note", [
|
|
549
|
+
formatRemaining(cached.expiresAt - Date.now())
|
|
550
|
+
]);
|
|
551
|
+
}
|
|
552
|
+
__name(formatCacheNote, "formatCacheNote");
|
|
553
|
+
function clamp2(v, min, max) {
|
|
554
|
+
return Math.max(min, Math.min(max, v));
|
|
555
|
+
}
|
|
556
|
+
__name(clamp2, "clamp");
|
|
557
|
+
function isSafeAlias(name2) {
|
|
558
|
+
return /^[a-zA-Z][a-zA-Z0-9_-]{0,31}$/.test(name2);
|
|
559
|
+
}
|
|
560
|
+
__name(isSafeAlias, "isSafeAlias");
|
|
561
|
+
function range(start, end) {
|
|
562
|
+
const out = [];
|
|
563
|
+
for (let i = start; i < end; i++) out.push(i);
|
|
564
|
+
return out;
|
|
565
|
+
}
|
|
566
|
+
__name(range, "range");
|
|
567
|
+
|
|
568
|
+
// src/index.ts
|
|
569
|
+
var name = "argus";
|
|
570
|
+
var inject = ["server"];
|
|
571
|
+
var Config = import_koishi2.Schema.object({
|
|
572
|
+
path: import_koishi2.Schema.string().default("/argus"),
|
|
573
|
+
token: import_koishi2.Schema.string().role("secret").default(""),
|
|
574
|
+
commandName: import_koishi2.Schema.string().default("peek"),
|
|
575
|
+
blur: import_koishi2.Schema.natural().min(0).max(200).default(40),
|
|
576
|
+
blurMode: import_koishi2.Schema.union(["gaussian", "fast"]).default("fast"),
|
|
577
|
+
minBlur: import_koishi2.Schema.natural().min(0).max(200).default(10),
|
|
578
|
+
maxImageBytes: import_koishi2.Schema.natural().default(8 * 1024 * 1024),
|
|
579
|
+
timeout: import_koishi2.Schema.natural().default(15e3),
|
|
580
|
+
cacheDuration: import_koishi2.Schema.natural().default(5 * 60 * 1e3),
|
|
581
|
+
registerAlias: import_koishi2.Schema.boolean().default(true),
|
|
582
|
+
authority: import_koishi2.Schema.natural().default(1),
|
|
583
|
+
forceAuthority: import_koishi2.Schema.natural().default(3)
|
|
584
|
+
}).i18n({
|
|
585
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
586
|
+
"zh-CN": require_zh_CN_schema(),
|
|
587
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
588
|
+
"en-US": require_en_US_schema()
|
|
589
|
+
});
|
|
590
|
+
function apply(ctx, config) {
|
|
591
|
+
ctx.i18n.define("zh-CN", require_zh_CN());
|
|
592
|
+
ctx.i18n.define("en-US", require_en_US());
|
|
593
|
+
if (!config.token) {
|
|
594
|
+
ctx.logger.warn(
|
|
595
|
+
"token 未配置,所有客户端连接都会被拒绝。请在配置中设置 token。"
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
const cache = new PeekCache(config.cacheDuration);
|
|
599
|
+
const server = new ArgusServer(ctx, {
|
|
600
|
+
path: config.path,
|
|
601
|
+
token: config.token,
|
|
602
|
+
timeout: config.timeout,
|
|
603
|
+
maxImageBytes: config.maxImageBytes,
|
|
604
|
+
onClientChange: /* @__PURE__ */ __name((event) => {
|
|
605
|
+
if (event.type === "connect") {
|
|
606
|
+
ctx.emit("argus/client-connect", event.name);
|
|
607
|
+
} else {
|
|
608
|
+
ctx.emit("argus/client-disconnect", event.name);
|
|
609
|
+
}
|
|
610
|
+
}, "onClientChange")
|
|
611
|
+
});
|
|
612
|
+
applyCommands(ctx, server, config, cache);
|
|
613
|
+
ctx.on("dispose", () => {
|
|
614
|
+
cache.clear();
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
__name(apply, "apply");
|
|
618
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
619
|
+
0 && (module.exports = {
|
|
620
|
+
Config,
|
|
621
|
+
apply,
|
|
622
|
+
inject,
|
|
623
|
+
name
|
|
624
|
+
});
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Context, Schema } from 'koishi';
|
|
2
|
+
import type { BlurMode } from './blur';
|
|
3
|
+
export declare const name = "argus";
|
|
4
|
+
export declare const inject: string[];
|
|
5
|
+
export interface Config {
|
|
6
|
+
path: string;
|
|
7
|
+
token: string;
|
|
8
|
+
commandName: string;
|
|
9
|
+
blur: number;
|
|
10
|
+
blurMode: BlurMode;
|
|
11
|
+
minBlur: number;
|
|
12
|
+
maxImageBytes: number;
|
|
13
|
+
timeout: number;
|
|
14
|
+
cacheDuration: number;
|
|
15
|
+
registerAlias: boolean;
|
|
16
|
+
authority: number;
|
|
17
|
+
forceAuthority: number;
|
|
18
|
+
}
|
|
19
|
+
export declare const Config: Schema<Config>;
|
|
20
|
+
declare module 'koishi' {
|
|
21
|
+
interface Events {
|
|
22
|
+
'argus/client-connect'(name: string): void;
|
|
23
|
+
'argus/client-disconnect'(name: string): void;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export declare function apply(ctx: Context, config: Config): void;
|