argus-eye 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/bin.mjs +7 -0
- package/lib/capture.d.ts +11 -0
- package/lib/client.d.ts +42 -0
- package/lib/config.d.ts +19 -0
- package/lib/fullscreen.d.ts +50 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.mjs +693 -0
- package/lib/logger.d.ts +8 -0
- package/lib/protocol.d.ts +60 -0
- package/package.json +72 -0
- package/readme.md +73 -0
package/bin.mjs
ADDED
package/lib/capture.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DisplayInfo } from './protocol';
|
|
2
|
+
export interface CaptureOptions {
|
|
3
|
+
display?: number | string;
|
|
4
|
+
format?: 'png' | 'jpg';
|
|
5
|
+
}
|
|
6
|
+
export declare function listDisplays(): Promise<DisplayInfo[]>;
|
|
7
|
+
export declare function capture(options?: CaptureOptions): Promise<{
|
|
8
|
+
buffer: Buffer;
|
|
9
|
+
mime: string;
|
|
10
|
+
display?: number | string;
|
|
11
|
+
}>;
|
package/lib/client.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ResolvedConfig } from './config';
|
|
2
|
+
import type { DisplayInfo } from './protocol';
|
|
3
|
+
export interface ClientHooks {
|
|
4
|
+
onConnected?: (info: {
|
|
5
|
+
displays: DisplayInfo[];
|
|
6
|
+
}) => void;
|
|
7
|
+
onDisconnected?: (reason: string) => void;
|
|
8
|
+
}
|
|
9
|
+
export declare class ArgusClient {
|
|
10
|
+
private readonly config;
|
|
11
|
+
private readonly version;
|
|
12
|
+
private readonly hooks;
|
|
13
|
+
private socket?;
|
|
14
|
+
private displays;
|
|
15
|
+
/** 服务端看到的 display 索引(0..N-1) → 本机原生 id 的映射。 */
|
|
16
|
+
private nativeIds;
|
|
17
|
+
private stopped;
|
|
18
|
+
private reconnectAttempts;
|
|
19
|
+
private reconnectTimer?;
|
|
20
|
+
private heartbeatTimer?;
|
|
21
|
+
private lastSeen;
|
|
22
|
+
/** 是否已成功握手(hello_ack ok)。close 时根据这个判断是不是“瞬关”。 */
|
|
23
|
+
private handshaken;
|
|
24
|
+
private openedAt;
|
|
25
|
+
private fullscreen;
|
|
26
|
+
constructor(config: ResolvedConfig, version: string, hooks?: ClientHooks);
|
|
27
|
+
start(): Promise<void>;
|
|
28
|
+
stop(): void;
|
|
29
|
+
private connect;
|
|
30
|
+
private handleFrame;
|
|
31
|
+
private onHelloAck;
|
|
32
|
+
private onPeek;
|
|
33
|
+
/**
|
|
34
|
+
* 解析 cli 配置里 `--display` 在 displays 列表中对应的索引。
|
|
35
|
+
* 支持数字索引 / 名字(不区分大小写) / 原生 id。
|
|
36
|
+
*/
|
|
37
|
+
private resolveDefaultIndex;
|
|
38
|
+
private startHeartbeat;
|
|
39
|
+
private clearHeartbeat;
|
|
40
|
+
private scheduleReconnect;
|
|
41
|
+
private send;
|
|
42
|
+
}
|
package/lib/config.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface ResolvedConfig {
|
|
2
|
+
server: string;
|
|
3
|
+
token: string;
|
|
4
|
+
name: string;
|
|
5
|
+
display?: number | string;
|
|
6
|
+
format: 'png' | 'jpg';
|
|
7
|
+
reconnect: boolean;
|
|
8
|
+
backoff: number;
|
|
9
|
+
color: boolean;
|
|
10
|
+
detectFullscreen: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface CliFlags {
|
|
13
|
+
help?: boolean;
|
|
14
|
+
version?: boolean;
|
|
15
|
+
listDisplays?: boolean;
|
|
16
|
+
config?: ResolvedConfig;
|
|
17
|
+
}
|
|
18
|
+
export declare function parseArgs(argv: string[]): CliFlags;
|
|
19
|
+
export declare function getHelpText(): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** 当前活动窗口的简要信息,无法获取时为 undefined。 */
|
|
2
|
+
export interface ActiveWindowInfo {
|
|
3
|
+
app: string;
|
|
4
|
+
title: string;
|
|
5
|
+
bounds: {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
};
|
|
11
|
+
displayBounds?: {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 检测器:懒加载 `get-windows`(原生 npm 包,gyp 编译)。
|
|
20
|
+
* 该模块在某些平台 / 环境(Wayland、CI 容器、缺乏权限的 macOS)上不可用,
|
|
21
|
+
* 这种情况下返回 undefined,调用方按"无法判断"处理。
|
|
22
|
+
*/
|
|
23
|
+
export declare class FullscreenDetector {
|
|
24
|
+
private enabled;
|
|
25
|
+
private mod?;
|
|
26
|
+
private loading?;
|
|
27
|
+
private warnedFailure;
|
|
28
|
+
constructor(enabled: boolean);
|
|
29
|
+
setEnabled(enabled: boolean): void;
|
|
30
|
+
isEnabled(): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* 获取当前活动窗口。无法获取时返回 undefined。
|
|
33
|
+
*/
|
|
34
|
+
getActiveWindow(): Promise<ActiveWindowInfo | undefined>;
|
|
35
|
+
/**
|
|
36
|
+
* 判断 `win` 是否在 `display` 上覆盖整块屏(全屏游戏 / 全屏视频 / 最大化应用)。
|
|
37
|
+
*
|
|
38
|
+
* 注意:`get-windows` 在 Windows 上返回的是 DPI 缩放后的逻辑像素,
|
|
39
|
+
* `screenshot-desktop` 报告的是物理像素。所以比较的是缩放比例,
|
|
40
|
+
* 该比例应该匹配某个常见的 DPI 缩放因子(100% / 125% / 150% / ...)。
|
|
41
|
+
*
|
|
42
|
+
* 这个检查会把"最大化的应用"也视作 fullscreen,这是有意为之 ——
|
|
43
|
+
* 用户的意图是「看到某个独占应用就别曝光画面」。
|
|
44
|
+
*/
|
|
45
|
+
isFullscreen(win: ActiveWindowInfo, display: {
|
|
46
|
+
width?: number;
|
|
47
|
+
height?: number;
|
|
48
|
+
}): boolean;
|
|
49
|
+
private load;
|
|
50
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function start(): Promise<void>;
|
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
3
|
+
|
|
4
|
+
// src/index.ts
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
|
|
7
|
+
// src/client.ts
|
|
8
|
+
import WebSocket from "ws";
|
|
9
|
+
|
|
10
|
+
// src/logger.ts
|
|
11
|
+
import kleur from "kleur";
|
|
12
|
+
var colorEnabled = true;
|
|
13
|
+
function setColorEnabled(value) {
|
|
14
|
+
colorEnabled = value;
|
|
15
|
+
kleur.enabled = value;
|
|
16
|
+
}
|
|
17
|
+
__name(setColorEnabled, "setColorEnabled");
|
|
18
|
+
function ts() {
|
|
19
|
+
const d = /* @__PURE__ */ new Date();
|
|
20
|
+
const pad = /* @__PURE__ */ __name((n) => String(n).padStart(2, "0"), "pad");
|
|
21
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
22
|
+
}
|
|
23
|
+
__name(ts, "ts");
|
|
24
|
+
function tag(label, color) {
|
|
25
|
+
const t = `[${ts()}]`;
|
|
26
|
+
if (!colorEnabled) return `${t} ${label}`;
|
|
27
|
+
return `${kleur.gray(t)} ${color(label)}`;
|
|
28
|
+
}
|
|
29
|
+
__name(tag, "tag");
|
|
30
|
+
var logger = {
|
|
31
|
+
info(...args) {
|
|
32
|
+
console.log(tag("info ", kleur.cyan), ...args);
|
|
33
|
+
},
|
|
34
|
+
success(...args) {
|
|
35
|
+
console.log(tag("ok ", kleur.green), ...args);
|
|
36
|
+
},
|
|
37
|
+
warn(...args) {
|
|
38
|
+
console.warn(tag("warn ", kleur.yellow), ...args);
|
|
39
|
+
},
|
|
40
|
+
error(...args) {
|
|
41
|
+
console.error(tag("error", kleur.red), ...args);
|
|
42
|
+
},
|
|
43
|
+
debug(...args) {
|
|
44
|
+
if (!process.env.ARGUS_DEBUG) return;
|
|
45
|
+
console.log(tag("debug", kleur.magenta), ...args);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// src/capture.ts
|
|
50
|
+
import screenshot from "screenshot-desktop";
|
|
51
|
+
async function listDisplays() {
|
|
52
|
+
const raw = await screenshot.listDisplays();
|
|
53
|
+
return raw.map((d) => ({
|
|
54
|
+
id: d.id,
|
|
55
|
+
name: d.name,
|
|
56
|
+
width: d.width,
|
|
57
|
+
height: d.height,
|
|
58
|
+
primary: d.primary
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
__name(listDisplays, "listDisplays");
|
|
62
|
+
async function capture(options = {}) {
|
|
63
|
+
const format = options.format ?? "jpg";
|
|
64
|
+
const mime = format === "png" ? "image/png" : "image/jpeg";
|
|
65
|
+
const opts = { format };
|
|
66
|
+
if (options.display !== void 0) {
|
|
67
|
+
opts.screen = options.display;
|
|
68
|
+
}
|
|
69
|
+
const buffer = await screenshot(opts);
|
|
70
|
+
return { buffer, mime, display: options.display };
|
|
71
|
+
}
|
|
72
|
+
__name(capture, "capture");
|
|
73
|
+
|
|
74
|
+
// src/fullscreen.ts
|
|
75
|
+
var FullscreenDetector = class {
|
|
76
|
+
constructor(enabled) {
|
|
77
|
+
this.enabled = enabled;
|
|
78
|
+
}
|
|
79
|
+
static {
|
|
80
|
+
__name(this, "FullscreenDetector");
|
|
81
|
+
}
|
|
82
|
+
mod;
|
|
83
|
+
loading;
|
|
84
|
+
warnedFailure = false;
|
|
85
|
+
setEnabled(enabled) {
|
|
86
|
+
this.enabled = enabled;
|
|
87
|
+
}
|
|
88
|
+
isEnabled() {
|
|
89
|
+
return this.enabled;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 获取当前活动窗口。无法获取时返回 undefined。
|
|
93
|
+
*/
|
|
94
|
+
async getActiveWindow() {
|
|
95
|
+
if (!this.enabled) return void 0;
|
|
96
|
+
const mod = await this.load();
|
|
97
|
+
if (!mod) return void 0;
|
|
98
|
+
try {
|
|
99
|
+
const win = await mod.activeWindow({
|
|
100
|
+
// 不要在 macOS 上触发权限弹窗
|
|
101
|
+
accessibilityPermission: false,
|
|
102
|
+
screenRecordingPermission: false
|
|
103
|
+
});
|
|
104
|
+
if (!win || !win.bounds) return void 0;
|
|
105
|
+
return {
|
|
106
|
+
app: win.owner?.name ?? "",
|
|
107
|
+
title: win.title ?? "",
|
|
108
|
+
bounds: win.bounds
|
|
109
|
+
};
|
|
110
|
+
} catch (err) {
|
|
111
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
112
|
+
logger.debug(`activeWindow failed: ${message}`);
|
|
113
|
+
return void 0;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* 判断 `win` 是否在 `display` 上覆盖整块屏(全屏游戏 / 全屏视频 / 最大化应用)。
|
|
118
|
+
*
|
|
119
|
+
* 注意:`get-windows` 在 Windows 上返回的是 DPI 缩放后的逻辑像素,
|
|
120
|
+
* `screenshot-desktop` 报告的是物理像素。所以比较的是缩放比例,
|
|
121
|
+
* 该比例应该匹配某个常见的 DPI 缩放因子(100% / 125% / 150% / ...)。
|
|
122
|
+
*
|
|
123
|
+
* 这个检查会把"最大化的应用"也视作 fullscreen,这是有意为之 ——
|
|
124
|
+
* 用户的意图是「看到某个独占应用就别曝光画面」。
|
|
125
|
+
*/
|
|
126
|
+
isFullscreen(win, display) {
|
|
127
|
+
if (!display.width || !display.height) return false;
|
|
128
|
+
const { bounds } = win;
|
|
129
|
+
if (bounds.width <= 0 || bounds.height <= 0) return false;
|
|
130
|
+
const widthRatio = bounds.width / display.width;
|
|
131
|
+
const heightRatio = bounds.height / display.height;
|
|
132
|
+
const ratioMismatch = Math.abs(widthRatio - heightRatio) / Math.max(widthRatio, heightRatio);
|
|
133
|
+
if (ratioMismatch > 0.02) return false;
|
|
134
|
+
const scale = (widthRatio + heightRatio) / 2;
|
|
135
|
+
const dpiScales = [1, 0.8, 2 / 3, 4 / 7, 0.5, 4 / 9, 0.4];
|
|
136
|
+
const matchesDpi = dpiScales.some((s) => Math.abs(scale - s) < 0.01);
|
|
137
|
+
if (!matchesDpi) return false;
|
|
138
|
+
return Math.abs(bounds.x) <= 8 && Math.abs(bounds.y) <= 8;
|
|
139
|
+
}
|
|
140
|
+
load() {
|
|
141
|
+
if (this.mod) return Promise.resolve(this.mod);
|
|
142
|
+
if (this.loading) return this.loading;
|
|
143
|
+
this.loading = (async () => {
|
|
144
|
+
try {
|
|
145
|
+
const mod = await import("get-windows");
|
|
146
|
+
this.mod = mod;
|
|
147
|
+
return mod;
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if (!this.warnedFailure) {
|
|
150
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
151
|
+
logger.warn(
|
|
152
|
+
`fullscreen detection unavailable: ${message}. (get-windows failed to load; install it manually if you want this feature.)`
|
|
153
|
+
);
|
|
154
|
+
this.warnedFailure = true;
|
|
155
|
+
}
|
|
156
|
+
return void 0;
|
|
157
|
+
}
|
|
158
|
+
})();
|
|
159
|
+
return this.loading;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// src/client.ts
|
|
164
|
+
var HEARTBEAT_TIMEOUT_MS = 9e4;
|
|
165
|
+
var ArgusClient = class {
|
|
166
|
+
constructor(config, version, hooks = {}) {
|
|
167
|
+
this.config = config;
|
|
168
|
+
this.version = version;
|
|
169
|
+
this.hooks = hooks;
|
|
170
|
+
this.fullscreen = new FullscreenDetector(config.detectFullscreen);
|
|
171
|
+
}
|
|
172
|
+
static {
|
|
173
|
+
__name(this, "ArgusClient");
|
|
174
|
+
}
|
|
175
|
+
socket;
|
|
176
|
+
displays = [];
|
|
177
|
+
/** 服务端看到的 display 索引(0..N-1) → 本机原生 id 的映射。 */
|
|
178
|
+
nativeIds = [];
|
|
179
|
+
stopped = false;
|
|
180
|
+
reconnectAttempts = 0;
|
|
181
|
+
reconnectTimer;
|
|
182
|
+
heartbeatTimer;
|
|
183
|
+
lastSeen = 0;
|
|
184
|
+
/** 是否已成功握手(hello_ack ok)。close 时根据这个判断是不是“瞬关”。 */
|
|
185
|
+
handshaken = false;
|
|
186
|
+
openedAt = 0;
|
|
187
|
+
fullscreen;
|
|
188
|
+
async start() {
|
|
189
|
+
try {
|
|
190
|
+
const native = await listDisplays();
|
|
191
|
+
this.nativeIds = native.map((d) => d.id);
|
|
192
|
+
this.displays = native.map((d, i) => ({
|
|
193
|
+
id: i,
|
|
194
|
+
name: d.name ?? String(d.id),
|
|
195
|
+
width: d.width,
|
|
196
|
+
height: d.height,
|
|
197
|
+
primary: d.primary
|
|
198
|
+
}));
|
|
199
|
+
} catch (err) {
|
|
200
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
201
|
+
logger.warn(`failed to list displays: ${message}`);
|
|
202
|
+
this.displays = [];
|
|
203
|
+
this.nativeIds = [];
|
|
204
|
+
}
|
|
205
|
+
this.connect();
|
|
206
|
+
}
|
|
207
|
+
stop() {
|
|
208
|
+
this.stopped = true;
|
|
209
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
210
|
+
this.clearHeartbeat();
|
|
211
|
+
if (this.socket) {
|
|
212
|
+
try {
|
|
213
|
+
this.send({ type: "bye", reason: "cli_exit" });
|
|
214
|
+
} catch {
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
this.socket.close(1e3, "cli_exit");
|
|
218
|
+
} catch {
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
connect() {
|
|
223
|
+
if (this.stopped) return;
|
|
224
|
+
logger.info(`connecting to ${this.config.server} ...`);
|
|
225
|
+
let socket;
|
|
226
|
+
try {
|
|
227
|
+
socket = new WebSocket(this.config.server);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
230
|
+
logger.error(`failed to create socket: ${message}`);
|
|
231
|
+
this.scheduleReconnect();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
this.socket = socket;
|
|
235
|
+
this.handshaken = false;
|
|
236
|
+
this.openedAt = 0;
|
|
237
|
+
socket.on("open", () => {
|
|
238
|
+
this.openedAt = Date.now();
|
|
239
|
+
this.lastSeen = Date.now();
|
|
240
|
+
const defaultIndex = this.resolveDefaultIndex();
|
|
241
|
+
this.send({
|
|
242
|
+
type: "hello",
|
|
243
|
+
name: this.config.name,
|
|
244
|
+
token: this.config.token,
|
|
245
|
+
version: this.version,
|
|
246
|
+
displays: this.displays,
|
|
247
|
+
defaultDisplay: defaultIndex
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
socket.on("message", (raw, isBinary) => {
|
|
251
|
+
if (isBinary) return;
|
|
252
|
+
this.lastSeen = Date.now();
|
|
253
|
+
const text = typeof raw === "string" ? raw : raw.toString("utf8");
|
|
254
|
+
let frame;
|
|
255
|
+
try {
|
|
256
|
+
frame = JSON.parse(text);
|
|
257
|
+
} catch {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
this.handleFrame(frame);
|
|
261
|
+
});
|
|
262
|
+
socket.on("close", (code, reasonBuf) => {
|
|
263
|
+
const reason = reasonBuf?.toString() || `code_${code}`;
|
|
264
|
+
this.clearHeartbeat();
|
|
265
|
+
this.socket = void 0;
|
|
266
|
+
this.hooks.onDisconnected?.(reason);
|
|
267
|
+
if (code === 4001) {
|
|
268
|
+
logger.error("auth failed: token rejected by server. exiting.");
|
|
269
|
+
process.exit(2);
|
|
270
|
+
}
|
|
271
|
+
if (code === 4004) {
|
|
272
|
+
logger.error(
|
|
273
|
+
`auth failed: server rejected name "${this.config.name}". exiting.`
|
|
274
|
+
);
|
|
275
|
+
process.exit(2);
|
|
276
|
+
}
|
|
277
|
+
const justOpenedAndClosed = this.openedAt > 0 && !this.handshaken && Date.now() - this.openedAt < 3e3;
|
|
278
|
+
if (justOpenedAndClosed && (code === 1005 || code === 1006)) {
|
|
279
|
+
logger.warn(
|
|
280
|
+
`disconnected immediately after open (${code} ${reason}). check the WebSocket path: server is configured at ${this.config.server} ?`
|
|
281
|
+
);
|
|
282
|
+
} else {
|
|
283
|
+
logger.warn(`disconnected (${code} ${reason})`);
|
|
284
|
+
}
|
|
285
|
+
this.scheduleReconnect();
|
|
286
|
+
});
|
|
287
|
+
socket.on("error", (err) => {
|
|
288
|
+
logger.warn(`socket error: ${err.message}`);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
handleFrame(frame) {
|
|
292
|
+
switch (frame.type) {
|
|
293
|
+
case "hello_ack":
|
|
294
|
+
return this.onHelloAck(frame);
|
|
295
|
+
case "peek":
|
|
296
|
+
return void this.onPeek(frame);
|
|
297
|
+
case "ping":
|
|
298
|
+
this.send({ type: "pong", t: frame.t });
|
|
299
|
+
return;
|
|
300
|
+
case "pong":
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
onHelloAck(frame) {
|
|
305
|
+
if (!frame.ok) {
|
|
306
|
+
logger.error(`server rejected hello: ${frame.error ?? "unknown"}`);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
this.handshaken = true;
|
|
310
|
+
this.reconnectAttempts = 0;
|
|
311
|
+
logger.success(
|
|
312
|
+
`connected as "${this.config.name}"; ${this.displays.length} display(s) reported`
|
|
313
|
+
);
|
|
314
|
+
for (const d of this.displays) {
|
|
315
|
+
const size = d.width && d.height ? `${d.width}x${d.height}` : "unknown";
|
|
316
|
+
logger.info(` · display ${d.id}: ${d.name ?? "-"} (${size})`);
|
|
317
|
+
}
|
|
318
|
+
logger.info(
|
|
319
|
+
`fullscreen detection: ${this.fullscreen.isEnabled() ? "on" : "off"}`
|
|
320
|
+
);
|
|
321
|
+
this.startHeartbeat();
|
|
322
|
+
this.hooks.onConnected?.({ displays: this.displays });
|
|
323
|
+
}
|
|
324
|
+
async onPeek(frame) {
|
|
325
|
+
let publicIndex;
|
|
326
|
+
let target;
|
|
327
|
+
if (frame.display !== void 0) {
|
|
328
|
+
const idx = typeof frame.display === "number" ? frame.display : Number(frame.display);
|
|
329
|
+
if (Number.isFinite(idx) && this.nativeIds[idx] !== void 0) {
|
|
330
|
+
publicIndex = idx;
|
|
331
|
+
target = this.nativeIds[idx];
|
|
332
|
+
} else {
|
|
333
|
+
this.send({
|
|
334
|
+
type: "peek_error",
|
|
335
|
+
id: frame.id,
|
|
336
|
+
error: `unknown_display:${frame.display}`
|
|
337
|
+
});
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
} else {
|
|
341
|
+
publicIndex = this.resolveDefaultIndex();
|
|
342
|
+
target = publicIndex !== void 0 ? this.nativeIds[publicIndex] : void 0;
|
|
343
|
+
}
|
|
344
|
+
if (this.fullscreen.isEnabled()) {
|
|
345
|
+
const win = await this.fullscreen.getActiveWindow();
|
|
346
|
+
const display = publicIndex !== void 0 ? this.displays[publicIndex] : void 0;
|
|
347
|
+
if (win && display && this.fullscreen.isFullscreen(win, display)) {
|
|
348
|
+
logger.info(
|
|
349
|
+
`peek #${frame.id} → busy: ${win.app || win.title || "fullscreen"}`
|
|
350
|
+
);
|
|
351
|
+
this.send({
|
|
352
|
+
type: "peek_busy",
|
|
353
|
+
id: frame.id,
|
|
354
|
+
app: win.app,
|
|
355
|
+
title: win.title,
|
|
356
|
+
reason: "fullscreen"
|
|
357
|
+
});
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const start2 = Date.now();
|
|
362
|
+
try {
|
|
363
|
+
const result = await capture({
|
|
364
|
+
display: target,
|
|
365
|
+
format: this.config.format
|
|
366
|
+
});
|
|
367
|
+
const base64 = result.buffer.toString("base64");
|
|
368
|
+
this.send({
|
|
369
|
+
type: "peek_result",
|
|
370
|
+
id: frame.id,
|
|
371
|
+
image: base64,
|
|
372
|
+
mime: result.mime,
|
|
373
|
+
display: frame.display
|
|
374
|
+
});
|
|
375
|
+
const elapsed = Date.now() - start2;
|
|
376
|
+
const kb = (result.buffer.length / 1024).toFixed(1);
|
|
377
|
+
logger.info(
|
|
378
|
+
`peek #${frame.id} → display ${frame.display ?? "default"} (${kb} KB ${this.config.format}, ${elapsed}ms)`
|
|
379
|
+
);
|
|
380
|
+
} catch (err) {
|
|
381
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
382
|
+
logger.warn(`peek #${frame.id} failed: ${message}`);
|
|
383
|
+
this.send({
|
|
384
|
+
type: "peek_error",
|
|
385
|
+
id: frame.id,
|
|
386
|
+
error: message
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* 解析 cli 配置里 `--display` 在 displays 列表中对应的索引。
|
|
392
|
+
* 支持数字索引 / 名字(不区分大小写) / 原生 id。
|
|
393
|
+
*/
|
|
394
|
+
resolveDefaultIndex() {
|
|
395
|
+
const target = this.config.display;
|
|
396
|
+
if (target === void 0) {
|
|
397
|
+
const primary = this.displays.findIndex((d) => d.primary);
|
|
398
|
+
if (primary >= 0) return primary;
|
|
399
|
+
return this.displays.length > 0 ? 0 : void 0;
|
|
400
|
+
}
|
|
401
|
+
if (typeof target === "number" && this.displays[target]) return target;
|
|
402
|
+
const lower = String(target).toLowerCase();
|
|
403
|
+
for (let i = 0; i < this.displays.length; i++) {
|
|
404
|
+
const d = this.displays[i];
|
|
405
|
+
const nativeId = this.nativeIds[i];
|
|
406
|
+
if (String(d.id) === String(target) || String(nativeId).toLowerCase() === lower || d.name && d.name.toLowerCase() === lower) {
|
|
407
|
+
return i;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return this.displays.length > 0 ? 0 : void 0;
|
|
411
|
+
}
|
|
412
|
+
startHeartbeat() {
|
|
413
|
+
this.clearHeartbeat();
|
|
414
|
+
this.heartbeatTimer = setInterval(() => {
|
|
415
|
+
const elapsed = Date.now() - this.lastSeen;
|
|
416
|
+
if (elapsed > HEARTBEAT_TIMEOUT_MS) {
|
|
417
|
+
logger.warn("heartbeat timeout, closing socket");
|
|
418
|
+
try {
|
|
419
|
+
this.socket?.terminate();
|
|
420
|
+
} catch {
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}, 15e3);
|
|
424
|
+
}
|
|
425
|
+
clearHeartbeat() {
|
|
426
|
+
if (this.heartbeatTimer) {
|
|
427
|
+
clearInterval(this.heartbeatTimer);
|
|
428
|
+
this.heartbeatTimer = void 0;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
scheduleReconnect() {
|
|
432
|
+
if (this.stopped) return;
|
|
433
|
+
if (!this.config.reconnect) {
|
|
434
|
+
logger.info("reconnect disabled, exiting.");
|
|
435
|
+
process.exit(1);
|
|
436
|
+
}
|
|
437
|
+
this.reconnectAttempts++;
|
|
438
|
+
const base = 1e3;
|
|
439
|
+
const exp = Math.min(this.reconnectAttempts - 1, 16);
|
|
440
|
+
const delay = Math.min(this.config.backoff, base * Math.pow(2, exp));
|
|
441
|
+
const jitter = Math.floor(Math.random() * 500);
|
|
442
|
+
const wait = delay + jitter;
|
|
443
|
+
logger.info(
|
|
444
|
+
`reconnecting in ${wait}ms (attempt #${this.reconnectAttempts}) ...`
|
|
445
|
+
);
|
|
446
|
+
this.reconnectTimer = setTimeout(() => this.connect(), wait);
|
|
447
|
+
}
|
|
448
|
+
send(frame) {
|
|
449
|
+
const socket = this.socket;
|
|
450
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
|
451
|
+
socket.send(JSON.stringify(frame));
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
// src/config.ts
|
|
456
|
+
import os from "node:os";
|
|
457
|
+
import fs from "node:fs";
|
|
458
|
+
import path from "node:path";
|
|
459
|
+
import yargsParser from "yargs-parser";
|
|
460
|
+
var HELP_TEXT = `argus-eye [options]
|
|
461
|
+
|
|
462
|
+
-s, --server <url> WebSocket 地址(必填,如 ws://host:5140/argus)
|
|
463
|
+
-t, --token <token> 鉴权 token(必填)
|
|
464
|
+
-n, --name <name> 上报给服务端的名字(默认: os.hostname())
|
|
465
|
+
-d, --display <id> 默认截哪块屏(数字 id,缺省=主屏)
|
|
466
|
+
--list-displays 列出本机显示器后退出
|
|
467
|
+
--format <png|jpg> 传输格式(默认 jpg,省流量)
|
|
468
|
+
--no-reconnect 禁用断线重连
|
|
469
|
+
--backoff <ms> 重连最大间隔(默认 30000)
|
|
470
|
+
--no-detect-fullscreen
|
|
471
|
+
关闭"全屏即拒拍"行为(默认开启)。打开时若检测到
|
|
472
|
+
当前焦点窗口铺满整块显示器(如全屏游戏 / 视频),
|
|
473
|
+
则向服务端返回 peek_busy 而不是真截图。
|
|
474
|
+
--config <path> JSON 配置文件
|
|
475
|
+
--no-color 关闭着色输出
|
|
476
|
+
-h, --help 显示帮助
|
|
477
|
+
-v, --version 显示版本
|
|
478
|
+
|
|
479
|
+
环境变量:ARGUS_SERVER / ARGUS_TOKEN / ARGUS_NAME 也会被读取,
|
|
480
|
+
优先级:argv > env > ~/.argus-eye.json (或 --config 指定) > 默认值。
|
|
481
|
+
`;
|
|
482
|
+
function parseArgs(argv) {
|
|
483
|
+
const parsed = yargsParser(argv, {
|
|
484
|
+
alias: {
|
|
485
|
+
server: ["s"],
|
|
486
|
+
token: ["t"],
|
|
487
|
+
name: ["n"],
|
|
488
|
+
display: ["d"],
|
|
489
|
+
help: ["h"],
|
|
490
|
+
version: ["v"]
|
|
491
|
+
},
|
|
492
|
+
string: ["server", "token", "name", "config", "format", "display"],
|
|
493
|
+
number: ["backoff"],
|
|
494
|
+
boolean: [
|
|
495
|
+
"help",
|
|
496
|
+
"version",
|
|
497
|
+
"reconnect",
|
|
498
|
+
"color",
|
|
499
|
+
"listDisplays",
|
|
500
|
+
"detectFullscreen"
|
|
501
|
+
],
|
|
502
|
+
configuration: {
|
|
503
|
+
"camel-case-expansion": true,
|
|
504
|
+
"strip-aliased": true,
|
|
505
|
+
"unknown-options-as-args": false
|
|
506
|
+
},
|
|
507
|
+
default: {
|
|
508
|
+
reconnect: true,
|
|
509
|
+
color: true,
|
|
510
|
+
listDisplays: false,
|
|
511
|
+
detectFullscreen: true
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
if (parsed.help) return { help: true };
|
|
515
|
+
if (parsed.version) return { version: true };
|
|
516
|
+
const fileConfig = loadConfigFile(
|
|
517
|
+
typeof parsed.config === "string" ? parsed.config : void 0
|
|
518
|
+
);
|
|
519
|
+
const env = process.env;
|
|
520
|
+
const merged = {
|
|
521
|
+
server: asString(parsed.server) ?? env.ARGUS_SERVER ?? asString(fileConfig.server),
|
|
522
|
+
token: asString(parsed.token) ?? env.ARGUS_TOKEN ?? asString(fileConfig.token),
|
|
523
|
+
name: asString(parsed.name) ?? env.ARGUS_NAME ?? asString(fileConfig.name) ?? os.hostname(),
|
|
524
|
+
display: asDisplay(parsed.display) ?? asDisplay(fileConfig.display) ?? void 0,
|
|
525
|
+
format: asString(parsed.format) ?? asString(fileConfig.format) ?? "jpg",
|
|
526
|
+
reconnect: asBoolean(parsed.reconnect) ?? asBoolean(fileConfig.reconnect) ?? true,
|
|
527
|
+
backoff: asNumber(parsed.backoff) ?? asNumber(fileConfig.backoff) ?? 3e4,
|
|
528
|
+
color: asBoolean(parsed.color) ?? true,
|
|
529
|
+
detectFullscreen: asBoolean(parsed.detectFullscreen) ?? asBoolean(fileConfig.detectFullscreen) ?? true
|
|
530
|
+
};
|
|
531
|
+
if (parsed.listDisplays) {
|
|
532
|
+
return {
|
|
533
|
+
listDisplays: true,
|
|
534
|
+
config: {
|
|
535
|
+
server: merged.server ?? "",
|
|
536
|
+
token: merged.token ?? "",
|
|
537
|
+
name: merged.name ?? os.hostname(),
|
|
538
|
+
display: merged.display,
|
|
539
|
+
format: merged.format ?? "jpg",
|
|
540
|
+
reconnect: merged.reconnect ?? true,
|
|
541
|
+
backoff: merged.backoff ?? 3e4,
|
|
542
|
+
color: merged.color ?? true,
|
|
543
|
+
detectFullscreen: merged.detectFullscreen ?? true
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
if (!merged.server) {
|
|
548
|
+
throw new Error("missing --server (or ARGUS_SERVER)");
|
|
549
|
+
}
|
|
550
|
+
if (!merged.token) {
|
|
551
|
+
throw new Error("missing --token (or ARGUS_TOKEN)");
|
|
552
|
+
}
|
|
553
|
+
if (merged.format !== "png" && merged.format !== "jpg") {
|
|
554
|
+
throw new Error(`invalid --format: ${merged.format}`);
|
|
555
|
+
}
|
|
556
|
+
return {
|
|
557
|
+
config: {
|
|
558
|
+
server: merged.server,
|
|
559
|
+
token: merged.token,
|
|
560
|
+
name: merged.name ?? os.hostname(),
|
|
561
|
+
display: merged.display,
|
|
562
|
+
format: merged.format,
|
|
563
|
+
reconnect: merged.reconnect ?? true,
|
|
564
|
+
backoff: merged.backoff ?? 3e4,
|
|
565
|
+
color: merged.color ?? true,
|
|
566
|
+
detectFullscreen: merged.detectFullscreen ?? true
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
__name(parseArgs, "parseArgs");
|
|
571
|
+
function getHelpText() {
|
|
572
|
+
return HELP_TEXT;
|
|
573
|
+
}
|
|
574
|
+
__name(getHelpText, "getHelpText");
|
|
575
|
+
function loadConfigFile(explicit) {
|
|
576
|
+
const candidates = [
|
|
577
|
+
explicit,
|
|
578
|
+
path.join(os.homedir(), ".argus-eye.json"),
|
|
579
|
+
path.join(os.homedir(), ".config", "argus-eye", "config.json")
|
|
580
|
+
].filter((p) => Boolean(p));
|
|
581
|
+
for (const file of candidates) {
|
|
582
|
+
try {
|
|
583
|
+
if (fs.existsSync(file)) {
|
|
584
|
+
const raw = fs.readFileSync(file, "utf-8");
|
|
585
|
+
return JSON.parse(raw);
|
|
586
|
+
}
|
|
587
|
+
} catch (err) {
|
|
588
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
589
|
+
console.warn(`failed to read config ${file}: ${message}`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return {};
|
|
593
|
+
}
|
|
594
|
+
__name(loadConfigFile, "loadConfigFile");
|
|
595
|
+
function asString(v) {
|
|
596
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
597
|
+
return void 0;
|
|
598
|
+
}
|
|
599
|
+
__name(asString, "asString");
|
|
600
|
+
function asNumber(v) {
|
|
601
|
+
if (typeof v === "number" && Number.isFinite(v)) return v;
|
|
602
|
+
if (typeof v === "string" && v.length > 0 && !Number.isNaN(Number(v))) {
|
|
603
|
+
return Number(v);
|
|
604
|
+
}
|
|
605
|
+
return void 0;
|
|
606
|
+
}
|
|
607
|
+
__name(asNumber, "asNumber");
|
|
608
|
+
function asBoolean(v) {
|
|
609
|
+
if (typeof v === "boolean") return v;
|
|
610
|
+
if (typeof v === "string") {
|
|
611
|
+
if (v === "true") return true;
|
|
612
|
+
if (v === "false") return false;
|
|
613
|
+
}
|
|
614
|
+
return void 0;
|
|
615
|
+
}
|
|
616
|
+
__name(asBoolean, "asBoolean");
|
|
617
|
+
function asDisplay(v) {
|
|
618
|
+
if (typeof v === "number" && Number.isFinite(v)) return v;
|
|
619
|
+
if (typeof v === "string" && v.length > 0) {
|
|
620
|
+
const n = Number(v);
|
|
621
|
+
if (!Number.isNaN(n) && /^-?\d+$/.test(v)) return n;
|
|
622
|
+
return v;
|
|
623
|
+
}
|
|
624
|
+
return void 0;
|
|
625
|
+
}
|
|
626
|
+
__name(asDisplay, "asDisplay");
|
|
627
|
+
|
|
628
|
+
// src/index.ts
|
|
629
|
+
var require2 = createRequire(import.meta.url);
|
|
630
|
+
var pkg = require2("../package.json");
|
|
631
|
+
async function start() {
|
|
632
|
+
let parsed;
|
|
633
|
+
try {
|
|
634
|
+
parsed = parseArgs(process.argv.slice(2));
|
|
635
|
+
} catch (err) {
|
|
636
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
637
|
+
process.stderr.write(`argus-eye: ${message}
|
|
638
|
+
|
|
639
|
+
`);
|
|
640
|
+
process.stderr.write(getHelpText());
|
|
641
|
+
process.exit(2);
|
|
642
|
+
}
|
|
643
|
+
if (parsed.help) {
|
|
644
|
+
process.stdout.write(getHelpText());
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (parsed.version) {
|
|
648
|
+
process.stdout.write(`argus-eye v${pkg.version}
|
|
649
|
+
`);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (parsed.config && !parsed.config.color) {
|
|
653
|
+
setColorEnabled(false);
|
|
654
|
+
} else {
|
|
655
|
+
setColorEnabled(true);
|
|
656
|
+
}
|
|
657
|
+
if (parsed.listDisplays) {
|
|
658
|
+
try {
|
|
659
|
+
const displays = await listDisplays();
|
|
660
|
+
if (displays.length === 0) {
|
|
661
|
+
process.stdout.write("no displays found\n");
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
displays.forEach((d, i) => {
|
|
665
|
+
const size = d.width && d.height ? `${d.width}x${d.height}` : "unknown";
|
|
666
|
+
process.stdout.write(
|
|
667
|
+
`${i} ${d.name ?? "-"} ${size}${d.primary ? " (primary)" : ""} [native:${d.id}]
|
|
668
|
+
`
|
|
669
|
+
);
|
|
670
|
+
});
|
|
671
|
+
} catch (err) {
|
|
672
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
673
|
+
process.stderr.write(`failed to list displays: ${message}
|
|
674
|
+
`);
|
|
675
|
+
process.exit(1);
|
|
676
|
+
}
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const config = parsed.config;
|
|
680
|
+
const client = new ArgusClient(config, pkg.version);
|
|
681
|
+
const handleSignal = /* @__PURE__ */ __name((signal) => {
|
|
682
|
+
logger.info(`received ${signal}, shutting down ...`);
|
|
683
|
+
client.stop();
|
|
684
|
+
setTimeout(() => process.exit(0), 200);
|
|
685
|
+
}, "handleSignal");
|
|
686
|
+
process.on("SIGINT", () => handleSignal("SIGINT"));
|
|
687
|
+
process.on("SIGTERM", () => handleSignal("SIGTERM"));
|
|
688
|
+
await client.start();
|
|
689
|
+
}
|
|
690
|
+
__name(start, "start");
|
|
691
|
+
export {
|
|
692
|
+
start
|
|
693
|
+
};
|
package/lib/logger.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function setColorEnabled(value: boolean): void;
|
|
2
|
+
export declare const logger: {
|
|
3
|
+
info(...args: unknown[]): void;
|
|
4
|
+
success(...args: unknown[]): void;
|
|
5
|
+
warn(...args: unknown[]): void;
|
|
6
|
+
error(...args: unknown[]): void;
|
|
7
|
+
debug(...args: unknown[]): void;
|
|
8
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export interface DisplayInfo {
|
|
2
|
+
id: number | string;
|
|
3
|
+
name?: string;
|
|
4
|
+
width?: number;
|
|
5
|
+
height?: number;
|
|
6
|
+
primary?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface HelloFrame {
|
|
9
|
+
type: 'hello';
|
|
10
|
+
name: string;
|
|
11
|
+
token: string;
|
|
12
|
+
version?: string;
|
|
13
|
+
displays?: DisplayInfo[];
|
|
14
|
+
defaultDisplay?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface HelloAckFrame {
|
|
17
|
+
type: 'hello_ack';
|
|
18
|
+
ok: boolean;
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface PeekRequestFrame {
|
|
22
|
+
type: 'peek';
|
|
23
|
+
id: string;
|
|
24
|
+
display?: number | string;
|
|
25
|
+
}
|
|
26
|
+
export interface PeekResultFrame {
|
|
27
|
+
type: 'peek_result';
|
|
28
|
+
id: string;
|
|
29
|
+
image: string;
|
|
30
|
+
mime?: string;
|
|
31
|
+
width?: number;
|
|
32
|
+
height?: number;
|
|
33
|
+
display?: number | string;
|
|
34
|
+
}
|
|
35
|
+
export interface PeekErrorFrame {
|
|
36
|
+
type: 'peek_error';
|
|
37
|
+
id: string;
|
|
38
|
+
error: string;
|
|
39
|
+
}
|
|
40
|
+
export interface PeekBusyFrame {
|
|
41
|
+
type: 'peek_busy';
|
|
42
|
+
id: string;
|
|
43
|
+
app?: string;
|
|
44
|
+
title?: string;
|
|
45
|
+
reason?: 'fullscreen' | string;
|
|
46
|
+
}
|
|
47
|
+
export interface PingFrame {
|
|
48
|
+
type: 'ping';
|
|
49
|
+
t?: number;
|
|
50
|
+
}
|
|
51
|
+
export interface PongFrame {
|
|
52
|
+
type: 'pong';
|
|
53
|
+
t?: number;
|
|
54
|
+
}
|
|
55
|
+
export interface ByeFrame {
|
|
56
|
+
type: 'bye';
|
|
57
|
+
reason?: string;
|
|
58
|
+
}
|
|
59
|
+
export type ServerFrame = HelloAckFrame | PeekRequestFrame | PingFrame | PongFrame;
|
|
60
|
+
export type ClientFrame = HelloFrame | PeekResultFrame | PeekErrorFrame | PeekBusyFrame | PingFrame | PongFrame | ByeFrame;
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "argus-eye",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Argus eye —— koishi-plugin-argus 的截图客户端 CLI。",
|
|
6
|
+
"author": "dingyi222666 <dingyi222666@foxmail.com>",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"bin": "./bin.mjs",
|
|
9
|
+
"main": "./lib/index.mjs",
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"files": [
|
|
12
|
+
"lib",
|
|
13
|
+
"bin.mjs",
|
|
14
|
+
"readme.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "yarn yakumo build",
|
|
18
|
+
"bump": "yarn yakumo version",
|
|
19
|
+
"dep": "yarn yakumo upgrade",
|
|
20
|
+
"pub": "yarn yakumo publish",
|
|
21
|
+
"lint": "yarn eslint src --ext=ts",
|
|
22
|
+
"lint-fix": "yarn eslint src --ext=ts --fix"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"kleur": "^4.1.5",
|
|
26
|
+
"screenshot-desktop": "^1.15.0",
|
|
27
|
+
"ws": "^8.18.0",
|
|
28
|
+
"yargs-parser": "^21.1.1"
|
|
29
|
+
},
|
|
30
|
+
"optionalDependencies": {
|
|
31
|
+
"get-windows": "^9.3.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^25.0.3",
|
|
35
|
+
"@types/screenshot-desktop": "^1.12.3",
|
|
36
|
+
"@types/ws": "^8.5.10",
|
|
37
|
+
"@types/yargs-parser": "^21.0.3",
|
|
38
|
+
"@typescript-eslint/eslint-plugin": "^7.18.1-alpha.3",
|
|
39
|
+
"@typescript-eslint/parser": "^8.45.1-alpha.0",
|
|
40
|
+
"esbuild": "^0.25.10",
|
|
41
|
+
"esbuild-register": "npm:@shigma/esbuild-register@^1.1.1",
|
|
42
|
+
"eslint": "^8.57.1",
|
|
43
|
+
"eslint-config-prettier": "^9.1.2",
|
|
44
|
+
"eslint-config-standard": "^17.1.0",
|
|
45
|
+
"eslint-plugin-import": "^2.32.0",
|
|
46
|
+
"eslint-plugin-n": "^16.6.2",
|
|
47
|
+
"eslint-plugin-prettier": "^5.5.4",
|
|
48
|
+
"eslint-plugin-promise": "^7.2.1",
|
|
49
|
+
"yakumo": "^1.0.0",
|
|
50
|
+
"yakumo-esbuild": "^1.0.0",
|
|
51
|
+
"yakumo-tsc": "^1.0.0"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=18.0.0"
|
|
55
|
+
},
|
|
56
|
+
"keywords": [
|
|
57
|
+
"argus",
|
|
58
|
+
"argus-eye",
|
|
59
|
+
"koishi",
|
|
60
|
+
"screenshot",
|
|
61
|
+
"remote",
|
|
62
|
+
"cli"
|
|
63
|
+
],
|
|
64
|
+
"homepage": "https://github.com/dingyi222666/argus-eye#readme",
|
|
65
|
+
"repository": {
|
|
66
|
+
"type": "git",
|
|
67
|
+
"url": "git+https://github.com/dingyi222666/argus-eye.git"
|
|
68
|
+
},
|
|
69
|
+
"bugs": {
|
|
70
|
+
"url": "https://github.com/dingyi222666/argus-eye/issues"
|
|
71
|
+
}
|
|
72
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# argus-eye
|
|
2
|
+
|
|
3
|
+
> [`koishi-plugin-argus`](https://www.npmjs.com/package/koishi-plugin-argus) 的截图客户端 CLI。
|
|
4
|
+
> 在你自己电脑上跑一个,群里就能用 `/peek` 偷窥你了(自带模糊)。
|
|
5
|
+
|
|
6
|
+
## 立即使用
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npx argus-eye \
|
|
10
|
+
--server ws://your-koishi-host:5140/argus \
|
|
11
|
+
--token your-secret-token \
|
|
12
|
+
--name dingyi
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## 命令行参数
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
argus-eye [options]
|
|
19
|
+
|
|
20
|
+
-s, --server <url> WebSocket 地址(必填,如 ws://host:5140/argus)
|
|
21
|
+
-t, --token <token> 鉴权 token(必填)
|
|
22
|
+
-n, --name <name> 上报给服务端的名字(默认: os.hostname())
|
|
23
|
+
群里 /peek <name> 即用此名
|
|
24
|
+
--list-displays 列出本机显示器后退出
|
|
25
|
+
-d, --display <id> 默认截哪块屏(数字 id,缺省=主屏)
|
|
26
|
+
--format <png|jpg> 传输格式(默认 jpg,更省流量)
|
|
27
|
+
--no-reconnect 禁用断线重连
|
|
28
|
+
--backoff <ms> 重连最大间隔(默认 30000)
|
|
29
|
+
--no-detect-fullscreen
|
|
30
|
+
关闭"全屏即拒拍"行为(默认开启)
|
|
31
|
+
--config <path> JSON 配置文件
|
|
32
|
+
--no-color 关闭着色输出
|
|
33
|
+
-h, --help 显示帮助
|
|
34
|
+
-v, --version 显示版本
|
|
35
|
+
|
|
36
|
+
## 全屏检测
|
|
37
|
+
|
|
38
|
+
CLI 默认开启全屏检测:当截屏时检测到当前焦点窗口铺满整块显示器(典型如全屏游戏 / 全屏视频),
|
|
39
|
+
就向服务端返回一段「客户端正忙:xxx」的提示而不是真截图。
|
|
40
|
+
群友看到的是程序名("League of Legends" / "Bilibili" 等),看不到画面。
|
|
41
|
+
如果你想关掉这个行为,加 `--no-detect-fullscreen`。
|
|
42
|
+
|
|
43
|
+
底层使用 [`get-windows`](https://www.npmjs.com/package/get-windows)(optional dependency)。
|
|
44
|
+
该包是原生 napi 模块,部分平台(Linux Wayland)不支持,加载失败时会自动降级为永远「不忙」。
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 配置文件
|
|
48
|
+
|
|
49
|
+
支持把常用参数写到 `~/.argus-eye.json` 或通过 `--config` 指定,命令行参数 > 环境变量 > 配置文件 > 默认值:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"server": "ws://my.box:5140/argus",
|
|
54
|
+
"token": "xxx",
|
|
55
|
+
"name": "dingyi-pc",
|
|
56
|
+
"display": 0,
|
|
57
|
+
"format": "jpg"
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
环境变量:`ARGUS_SERVER` / `ARGUS_TOKEN` / `ARGUS_NAME`。
|
|
62
|
+
|
|
63
|
+
## 平台说明
|
|
64
|
+
|
|
65
|
+
底层使用 [`screenshot-desktop`](https://www.npmjs.com/package/screenshot-desktop):
|
|
66
|
+
|
|
67
|
+
- macOS:内置 `screencapture`,无需额外依赖。
|
|
68
|
+
- Windows:自带打包脚本,无需额外依赖。
|
|
69
|
+
- Linux:需要安装 `imagemagick` 或 `scrot`。
|
|
70
|
+
|
|
71
|
+
## 许可
|
|
72
|
+
|
|
73
|
+
MIT
|