glimpse-sdk 0.3.0 → 0.7.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/README.md +33 -1
- package/dist/src/app.d.ts +25 -1
- package/dist/src/app.js +96 -4
- package/dist/src/events.d.ts +1 -1
- package/dist/src/events.js +1 -1
- package/dist/src/index.d.ts +4 -3
- package/dist/src/index.js +4 -3
- package/dist/src/ipc.d.ts +28 -0
- package/dist/src/ipc.js +214 -0
- package/dist/src/protocol.d.ts +2 -15
- package/dist/src/protocol.js +1 -18
- package/dist/src/widgets.d.ts +415 -256
- package/dist/src/widgets.js +379 -457
- package/package.json +1 -1
- package/src/app.ts +122 -5
- package/src/events.ts +2 -2
- package/src/index.ts +39 -35
- package/src/ipc.ts +233 -0
- package/src/protocol.ts +2 -23
- package/src/widgets.ts +334 -787
package/package.json
CHANGED
package/src/app.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createInterface } from "node:readline";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
2
3
|
|
|
3
4
|
import {
|
|
4
5
|
type CallbackEvent,
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
parseInitEvent,
|
|
14
15
|
} from "./events.js";
|
|
15
16
|
import { StatusItem } from "./protocol.js";
|
|
16
|
-
import { type TreeNode } from "./widgets.js";
|
|
17
|
+
import { type TreeNode, InlineHandlerRegistry } from "./widgets.js";
|
|
17
18
|
|
|
18
19
|
type Handler<EventT> = (event: EventT) => void | Promise<void>;
|
|
19
20
|
|
|
@@ -23,10 +24,25 @@ interface OutgoingMessage {
|
|
|
23
24
|
line: string;
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
interface ShowNotificationOptions {
|
|
28
|
+
body?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CommandResult {
|
|
32
|
+
stdout: string;
|
|
33
|
+
stderr: string;
|
|
34
|
+
rc: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface DismissNotificationArgs {
|
|
38
|
+
id: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
26
41
|
export abstract class Applet<State extends object> {
|
|
27
42
|
state: State;
|
|
28
43
|
|
|
29
44
|
private readonly handlerMap = new Map<string, Handler<CallbackEvent>>();
|
|
45
|
+
private inlineHandlerMap = new Map<string, (event: CallbackEvent) => void | Promise<void>>();
|
|
30
46
|
private readonly outgoing: OutgoingMessage[] = [];
|
|
31
47
|
private flushPromise: Promise<void> | null = null;
|
|
32
48
|
private renderQueued = false;
|
|
@@ -40,6 +56,10 @@ export abstract class Applet<State extends object> {
|
|
|
40
56
|
|
|
41
57
|
protected abstract initialState(): State;
|
|
42
58
|
|
|
59
|
+
protected cssClass(): string | null {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
43
63
|
protected async onStart(): Promise<void> {}
|
|
44
64
|
|
|
45
65
|
protected async onInit(_event: InitEvent): Promise<void> {}
|
|
@@ -79,10 +99,51 @@ export abstract class Applet<State extends object> {
|
|
|
79
99
|
this.register("toggle", id, handler as Handler<CallbackEvent>);
|
|
80
100
|
}
|
|
81
101
|
|
|
102
|
+
onPopover(handler: Handler<PopoverEvent>): void {
|
|
103
|
+
this.register("open", "popover", handler as Handler<CallbackEvent>);
|
|
104
|
+
this.register("close", "popover", handler as Handler<CallbackEvent>);
|
|
105
|
+
}
|
|
106
|
+
|
|
82
107
|
isPopoverOpen(): boolean {
|
|
83
108
|
return this.popoverOpen;
|
|
84
109
|
}
|
|
85
110
|
|
|
111
|
+
log(...args: unknown[]): void {
|
|
112
|
+
process.stderr.write(args.map(String).join(" ") + "\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
protected async showNotification(summary: string, options: ShowNotificationOptions = {}): Promise<void> {
|
|
116
|
+
const args = [summary];
|
|
117
|
+
if (options.body !== undefined) {
|
|
118
|
+
args.push(options.body);
|
|
119
|
+
}
|
|
120
|
+
await this.runDesktopCommand("notify-send", args);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
protected async openUri(uri: string): Promise<void> {
|
|
124
|
+
await this.runDesktopCommand("xdg-open", [uri]);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
protected async copyToClipboard(text: string): Promise<void> {
|
|
128
|
+
await this.runDesktopCommand("wl-copy", [], text);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
protected async runCommand(command: string[]): Promise<CommandResult> {
|
|
132
|
+
return await runCommand(command);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
protected dismissNotification(args: DismissNotificationArgs): void {
|
|
136
|
+
this.emitAction("dismiss_notification", args);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
protected closePopover(): void {
|
|
140
|
+
this.emitAction("close_popover", {});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
protected async runDesktopCommand(command: string, args: string[], input?: string): Promise<void> {
|
|
144
|
+
await runDesktopCommand([command, ...args], input);
|
|
145
|
+
}
|
|
146
|
+
|
|
86
147
|
async run(): Promise<void> {
|
|
87
148
|
process.stdout.on("error", (err: NodeJS.ErrnoException) => {
|
|
88
149
|
if (err.code === "EPIPE") {
|
|
@@ -91,6 +152,10 @@ export abstract class Applet<State extends object> {
|
|
|
91
152
|
});
|
|
92
153
|
|
|
93
154
|
await this.onStart();
|
|
155
|
+
const cssClass = this.cssClass();
|
|
156
|
+
if (cssClass !== null) {
|
|
157
|
+
process.stdout.write(`class ${cssClass}\n`);
|
|
158
|
+
}
|
|
94
159
|
await this.scheduleRender();
|
|
95
160
|
|
|
96
161
|
const rl = createInterface({
|
|
@@ -133,7 +198,13 @@ export abstract class Applet<State extends object> {
|
|
|
133
198
|
}
|
|
134
199
|
|
|
135
200
|
private async dispatchCallback(event: CallbackEvent): Promise<void> {
|
|
136
|
-
const
|
|
201
|
+
const key = `${event.event}:${event.id}`;
|
|
202
|
+
const inlineHandler = this.inlineHandlerMap.get(key);
|
|
203
|
+
if (inlineHandler !== undefined) {
|
|
204
|
+
await inlineHandler(event);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const handler = this.handlerMap.get(key);
|
|
137
208
|
if (handler !== undefined) {
|
|
138
209
|
await handler(event);
|
|
139
210
|
return;
|
|
@@ -183,10 +254,15 @@ export abstract class Applet<State extends object> {
|
|
|
183
254
|
}
|
|
184
255
|
|
|
185
256
|
const widget = await this.popover(this.state);
|
|
257
|
+
if (widget !== null) {
|
|
258
|
+
const registry = new InlineHandlerRegistry();
|
|
259
|
+
widget.bindHandlers?.(registry, []);
|
|
260
|
+
this.inlineHandlerMap = registry.handlers;
|
|
261
|
+
} else {
|
|
262
|
+
this.inlineHandlerMap = new Map();
|
|
263
|
+
}
|
|
186
264
|
const tree = { root: widget?.toProtocol() ?? null };
|
|
187
|
-
|
|
188
|
-
const shouldEmit = this.popoverOpen || this.lastTree === null || tree.root === null;
|
|
189
|
-
if (shouldEmit && !deepEqual(tree, this.lastTree)) {
|
|
265
|
+
if (!deepEqual(tree, this.lastTree)) {
|
|
190
266
|
this.lastTree = tree;
|
|
191
267
|
this.emit("popover", tree);
|
|
192
268
|
}
|
|
@@ -204,6 +280,10 @@ export abstract class Applet<State extends object> {
|
|
|
204
280
|
}
|
|
205
281
|
}
|
|
206
282
|
}
|
|
283
|
+
|
|
284
|
+
private emitAction(type: string, args: object): void {
|
|
285
|
+
this.emit("action", { type, arguments: args });
|
|
286
|
+
}
|
|
207
287
|
}
|
|
208
288
|
|
|
209
289
|
function parseLine(line: string): { command: string; data: unknown } | null {
|
|
@@ -228,3 +308,40 @@ function deepEqual(left: unknown, right: unknown): boolean {
|
|
|
228
308
|
function isPopoverEvent(event: CallbackEvent): event is PopoverEvent {
|
|
229
309
|
return event.event === "open" || event.event === "close";
|
|
230
310
|
}
|
|
311
|
+
|
|
312
|
+
export async function runCommand(command: string[]): Promise<CommandResult> {
|
|
313
|
+
return await runCommandWithInput(command);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function runDesktopCommand(command: string[], input?: string): Promise<void> {
|
|
317
|
+
const result = await runCommandWithInput(command, input);
|
|
318
|
+
if (result.rc !== 0) {
|
|
319
|
+
throw new Error(`${command[0]} exited with status ${result.rc}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function runCommandWithInput(command: string[], input?: string): Promise<CommandResult> {
|
|
324
|
+
if (command.length === 0) {
|
|
325
|
+
throw new Error("command must not be empty");
|
|
326
|
+
}
|
|
327
|
+
return await new Promise<CommandResult>((resolve, reject) => {
|
|
328
|
+
const child = spawn(command[0], command.slice(1), { stdio: ["pipe", "pipe", "pipe"] });
|
|
329
|
+
const stdout: Buffer[] = [];
|
|
330
|
+
const stderr: Buffer[] = [];
|
|
331
|
+
child.on("error", reject);
|
|
332
|
+
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
|
|
333
|
+
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
|
|
334
|
+
child.on("close", (code) => {
|
|
335
|
+
resolve({
|
|
336
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
337
|
+
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
338
|
+
rc: code ?? -1,
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
if (input !== undefined) {
|
|
342
|
+
child.stdin.end(input);
|
|
343
|
+
} else {
|
|
344
|
+
child.stdin.end();
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
}
|
package/src/events.ts
CHANGED
|
@@ -10,7 +10,7 @@ interface CallbackEventBase {
|
|
|
10
10
|
|
|
11
11
|
export interface ClickEvent extends CallbackEventBase {
|
|
12
12
|
event: "click";
|
|
13
|
-
button?: string;
|
|
13
|
+
button?: string | null;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export interface ScrollEvent extends CallbackEventBase {
|
|
@@ -57,7 +57,7 @@ export function parseCallbackEvent(payload: Record<string, unknown>): CallbackEv
|
|
|
57
57
|
const event = String(payload.type ?? payload.event ?? "");
|
|
58
58
|
const id = String(payload.id ?? "");
|
|
59
59
|
if (event === "click") {
|
|
60
|
-
return { id, event, button: payload.button === undefined ?
|
|
60
|
+
return { id, event, button: payload.button === undefined ? null : String(payload.button) };
|
|
61
61
|
}
|
|
62
62
|
if (event === "scroll") {
|
|
63
63
|
return {
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { Applet } from "./app.js";
|
|
1
|
+
export { Applet, runCommand } from "./app.js";
|
|
2
2
|
export {
|
|
3
3
|
type CallbackEvent,
|
|
4
4
|
type ChangeEvent,
|
|
@@ -11,54 +11,58 @@ export {
|
|
|
11
11
|
parseCallbackEvent,
|
|
12
12
|
parseInitEvent,
|
|
13
13
|
} from "./events.js";
|
|
14
|
-
export {
|
|
14
|
+
export { ipc, IpcError, Subscriber } from "./ipc.js";
|
|
15
|
+
export { StatusItem } from "./protocol.js";
|
|
15
16
|
export {
|
|
16
|
-
ActionItem,
|
|
17
|
-
type Align,
|
|
18
17
|
Badge,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
type BadgeKind,
|
|
19
|
+
BatteryHero,
|
|
20
|
+
BoxedList,
|
|
21
|
+
ButtonRow,
|
|
22
|
+
Calendar,
|
|
23
|
+
CameraIndicator,
|
|
24
|
+
Choice,
|
|
25
|
+
ChoiceList,
|
|
26
|
+
ChoiceTile,
|
|
27
|
+
CircleBox,
|
|
24
28
|
Column,
|
|
25
|
-
|
|
26
|
-
|
|
29
|
+
Container,
|
|
30
|
+
DateHero,
|
|
27
31
|
EmptyState,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
EventItem,
|
|
33
|
+
Events,
|
|
34
|
+
ExpanderTile,
|
|
35
|
+
Header,
|
|
31
36
|
Hero,
|
|
32
|
-
|
|
33
|
-
Item,
|
|
37
|
+
KeyValueGrid,
|
|
34
38
|
Label,
|
|
35
|
-
|
|
36
|
-
type LevelBarMode,
|
|
37
|
-
LinkButton,
|
|
38
|
-
ListBox,
|
|
39
|
-
MenuButton,
|
|
39
|
+
LocationIndicator,
|
|
40
40
|
Meter,
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
MicIndicator,
|
|
42
|
+
MutedIndicator,
|
|
43
43
|
type PagerAppearance,
|
|
44
44
|
PagerItem,
|
|
45
45
|
PagerStrip,
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
type
|
|
49
|
-
PropertyList,
|
|
46
|
+
PanelIndicator,
|
|
47
|
+
PopoverShell,
|
|
48
|
+
type PopoverSize,
|
|
50
49
|
Row,
|
|
50
|
+
ScreenCastIndicator,
|
|
51
51
|
Scroll,
|
|
52
|
-
|
|
53
|
-
Select,
|
|
54
|
-
SelectOption,
|
|
52
|
+
SegmentedTile,
|
|
55
53
|
Separator,
|
|
56
|
-
|
|
54
|
+
SliderTile,
|
|
57
55
|
Spinner,
|
|
58
56
|
StatusDot,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
type StatusDotStatus,
|
|
58
|
+
SwitchTile,
|
|
59
|
+
Tile,
|
|
62
60
|
type TreeNode,
|
|
63
|
-
|
|
61
|
+
WeatherForecastItem,
|
|
62
|
+
WeatherForecastList,
|
|
63
|
+
WeatherHourlyItem,
|
|
64
|
+
WeatherHourlyStrip,
|
|
65
|
+
WidgetBase,
|
|
66
|
+
WorldClock,
|
|
67
|
+
WorldClockRow,
|
|
64
68
|
} from "./widgets.js";
|
package/src/ipc.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Minimal client for the Glimpse IPC socket.
|
|
2
|
+
//
|
|
3
|
+
// `ipc(service)` resolves a `Subscriber` (no I/O — the connection is opened
|
|
4
|
+
// lazily). `Subscriber.listen` subscribes to an event channel and yields
|
|
5
|
+
// decoded `Event`s; `Subscriber.dispatch` sends an action and awaits the
|
|
6
|
+
// server ack on a one-shot connection. The wire protocol matches the
|
|
7
|
+
// `glimpse-shell watch` / `dispatch` CLIs.
|
|
8
|
+
|
|
9
|
+
import { createConnection, type Socket } from "node:net";
|
|
10
|
+
import { createInterface, type Interface } from "node:readline";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
/** One decoded event line; `fields` values are unescaped. */
|
|
14
|
+
export interface Event {
|
|
15
|
+
name: string;
|
|
16
|
+
ts: number;
|
|
17
|
+
fields: Record<string, string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Thrown on connection failure or a rejected dispatch (`ok=false`). */
|
|
21
|
+
export class IpcError extends Error {}
|
|
22
|
+
|
|
23
|
+
interface Conn {
|
|
24
|
+
socket: Socket;
|
|
25
|
+
rl: Interface;
|
|
26
|
+
lines: AsyncIterator<string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A resolved IPC endpoint. Cheap to create; holds only the socket path. */
|
|
30
|
+
export class Subscriber {
|
|
31
|
+
constructor(private readonly socketPath: string) {}
|
|
32
|
+
|
|
33
|
+
/** Subscribe to `channel` (an exact name, `prefix.*`, or `*`) and yield
|
|
34
|
+
* events until the server closes the connection. */
|
|
35
|
+
async *listen(channel: string): AsyncGenerator<Event> {
|
|
36
|
+
const conn = await this.connect();
|
|
37
|
+
try {
|
|
38
|
+
conn.socket.write(`subscribe ${channel}\n`);
|
|
39
|
+
for (;;) {
|
|
40
|
+
const next = await conn.lines.next();
|
|
41
|
+
if (next.done) return;
|
|
42
|
+
const line = next.value.trim();
|
|
43
|
+
if (line) yield parseEvent(line);
|
|
44
|
+
}
|
|
45
|
+
} finally {
|
|
46
|
+
close(conn);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Dispatch `action` with `params` on a fresh connection and await the
|
|
51
|
+
* ack. Resolves with the extra ack fields; rejects with `IpcError` if the
|
|
52
|
+
* server replies `ok=false`. */
|
|
53
|
+
async dispatch(
|
|
54
|
+
action: string,
|
|
55
|
+
params: Record<string, string> = {},
|
|
56
|
+
): Promise<Record<string, string>> {
|
|
57
|
+
validateToken("action", action, false);
|
|
58
|
+
const entries = Object.entries(params);
|
|
59
|
+
for (const [k] of entries) validateToken("param key", k, true);
|
|
60
|
+
const conn = await this.connect();
|
|
61
|
+
try {
|
|
62
|
+
let line = action;
|
|
63
|
+
for (const [k, v] of entries) {
|
|
64
|
+
line += ` ${k}=${escape(v)}`;
|
|
65
|
+
}
|
|
66
|
+
conn.socket.write(line + "\n");
|
|
67
|
+
const next = await conn.lines.next();
|
|
68
|
+
if (next.done) {
|
|
69
|
+
throw new IpcError("IPC server closed connection without ack");
|
|
70
|
+
}
|
|
71
|
+
return parseAck(next.value.trim());
|
|
72
|
+
} finally {
|
|
73
|
+
close(conn);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private connect(): Promise<Conn> {
|
|
78
|
+
return new Promise<Conn>((resolve, reject) => {
|
|
79
|
+
const socket = createConnection({ path: this.socketPath });
|
|
80
|
+
let connected = false;
|
|
81
|
+
// This handler stays attached for the socket's whole lifetime. Never
|
|
82
|
+
// remove it: an unhandled 'error' event on a Socket throws and crashes
|
|
83
|
+
// the process. `close()` only destroys the socket, it does not detach.
|
|
84
|
+
socket.on("error", (err: Error) => {
|
|
85
|
+
if (connected) {
|
|
86
|
+
// Post-connect transport error: end the stream cleanly so the
|
|
87
|
+
// line iterator completes instead of crashing the process.
|
|
88
|
+
socket.destroy();
|
|
89
|
+
} else {
|
|
90
|
+
reject(
|
|
91
|
+
new IpcError(
|
|
92
|
+
`cannot connect to IPC socket at ${this.socketPath}: ${err.message}`,
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
socket.once("connect", () => {
|
|
98
|
+
connected = true;
|
|
99
|
+
const rl = createInterface({ input: socket, crlfDelay: Infinity });
|
|
100
|
+
const lines = rl[Symbol.asyncIterator]();
|
|
101
|
+
void (async () => {
|
|
102
|
+
const hello = await lines.next();
|
|
103
|
+
if (hello.done || !hello.value.startsWith("hello")) {
|
|
104
|
+
rl.close();
|
|
105
|
+
socket.destroy();
|
|
106
|
+
reject(
|
|
107
|
+
new IpcError(
|
|
108
|
+
hello.done
|
|
109
|
+
? "IPC server closed connection before hello"
|
|
110
|
+
: `unexpected IPC greeting: ${hello.value}`,
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
resolve({ socket, rl, lines });
|
|
116
|
+
})();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Resolve the {@link Subscriber} for `service` (`"shell"` for the panel).
|
|
123
|
+
*
|
|
124
|
+
* The socket is `<dir>/<service>.sock` (`shell` maps to `ipc.sock`) where
|
|
125
|
+
* `<dir>` is `$GLIMPSE_IPC_DIR`, else `$XDG_RUNTIME_DIR/glimpse`. No
|
|
126
|
+
* connection is made here. */
|
|
127
|
+
export function ipc(service = "shell"): Subscriber {
|
|
128
|
+
return new Subscriber(socketPath(service));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function socketPath(service: string): string {
|
|
132
|
+
const override = process.env.GLIMPSE_IPC_DIR;
|
|
133
|
+
let dir: string;
|
|
134
|
+
if (override) {
|
|
135
|
+
dir = override;
|
|
136
|
+
} else {
|
|
137
|
+
const runtime = process.env.XDG_RUNTIME_DIR;
|
|
138
|
+
if (!runtime) {
|
|
139
|
+
throw new IpcError(
|
|
140
|
+
"neither GLIMPSE_IPC_DIR nor XDG_RUNTIME_DIR is set; " +
|
|
141
|
+
"cannot locate the Glimpse IPC socket",
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
dir = join(runtime, "glimpse");
|
|
145
|
+
}
|
|
146
|
+
return join(dir, service === "shell" ? "ipc.sock" : `${service}.sock`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function close(conn: Conn): void {
|
|
150
|
+
conn.rl.close();
|
|
151
|
+
conn.socket.destroy();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The wire protocol splits client lines on whitespace and never unescapes
|
|
155
|
+
// the command name or a field key, so an `action`/key with whitespace would
|
|
156
|
+
// forge extra tokens or whole client lines. Values are safe (escaped).
|
|
157
|
+
function validateToken(label: string, token: string, forbidEq: boolean): void {
|
|
158
|
+
if (token.length === 0) {
|
|
159
|
+
throw new IpcError(`IPC ${label} must not be empty`);
|
|
160
|
+
}
|
|
161
|
+
if (/\s/.test(token)) {
|
|
162
|
+
throw new IpcError(`IPC ${label} "${token}" must not contain whitespace`);
|
|
163
|
+
}
|
|
164
|
+
if (forbidEq && token.includes("=")) {
|
|
165
|
+
throw new IpcError(`IPC param key "${token}" must not contain '='`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function escape(s: string): string {
|
|
170
|
+
return s
|
|
171
|
+
.replaceAll("\\", "\\\\")
|
|
172
|
+
.replaceAll("\n", "\\n")
|
|
173
|
+
.replaceAll("\t", "\\t")
|
|
174
|
+
.replaceAll(" ", "\\s");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function unescape(s: string): string {
|
|
178
|
+
let out = "";
|
|
179
|
+
for (let i = 0; i < s.length; i++) {
|
|
180
|
+
if (s[i] !== "\\") {
|
|
181
|
+
out += s[i];
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const next = s[++i];
|
|
185
|
+
if (next === "s") out += " ";
|
|
186
|
+
else if (next === "n") out += "\n";
|
|
187
|
+
else if (next === "t") out += "\t";
|
|
188
|
+
else if (next === "\\") out += "\\";
|
|
189
|
+
else if (next === undefined) out += "\\";
|
|
190
|
+
else out += "\\" + next;
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function parseEvent(line: string): Event {
|
|
196
|
+
const tokens = line.split(/\s+/);
|
|
197
|
+
const name = tokens[0] ?? "";
|
|
198
|
+
let ts = 0;
|
|
199
|
+
const fields: Record<string, string> = {};
|
|
200
|
+
for (const token of tokens.slice(1)) {
|
|
201
|
+
const eq = token.indexOf("=");
|
|
202
|
+
if (eq < 0) continue;
|
|
203
|
+
const key = token.slice(0, eq);
|
|
204
|
+
const value = unescape(token.slice(eq + 1));
|
|
205
|
+
if (key === "ts" && /^\d+$/.test(value)) {
|
|
206
|
+
ts = Number(value);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
fields[key] = value;
|
|
210
|
+
}
|
|
211
|
+
return { name, ts, fields };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseAck(line: string): Record<string, string> {
|
|
215
|
+
const tokens = line.split(/\s+/);
|
|
216
|
+
if (tokens[0] !== "ack") {
|
|
217
|
+
throw new IpcError(`expected an ack, got: ${line}`);
|
|
218
|
+
}
|
|
219
|
+
let ok = false;
|
|
220
|
+
const fields: Record<string, string> = {};
|
|
221
|
+
for (const token of tokens.slice(1)) {
|
|
222
|
+
const eq = token.indexOf("=");
|
|
223
|
+
if (eq < 0) continue;
|
|
224
|
+
const key = token.slice(0, eq);
|
|
225
|
+
const value = unescape(token.slice(eq + 1));
|
|
226
|
+
if (key === "ok") ok = value === "true";
|
|
227
|
+
else fields[key] = value;
|
|
228
|
+
}
|
|
229
|
+
if (!ok) {
|
|
230
|
+
throw new IpcError(fields.error ?? "command failed");
|
|
231
|
+
}
|
|
232
|
+
return fields;
|
|
233
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -1,29 +1,8 @@
|
|
|
1
|
-
export type IconKind = "name" | "path";
|
|
2
|
-
|
|
3
|
-
export class Icon {
|
|
4
|
-
constructor(
|
|
5
|
-
public readonly kind: IconKind,
|
|
6
|
-
public readonly value: string,
|
|
7
|
-
) {}
|
|
8
|
-
|
|
9
|
-
static name(value: string): Icon {
|
|
10
|
-
return new Icon("name", value);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
static path(value: string): Icon {
|
|
14
|
-
return new Icon("path", value);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
toProtocol(): { name: string } | { path: string } {
|
|
18
|
-
return this.kind === "name" ? { name: this.value } : { path: this.value };
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
1
|
export class StatusItem {
|
|
23
2
|
constructor(
|
|
24
3
|
public readonly options: {
|
|
25
4
|
id?: string;
|
|
26
|
-
icon?:
|
|
5
|
+
icon?: string;
|
|
27
6
|
label?: string;
|
|
28
7
|
tooltip?: string;
|
|
29
8
|
} = {},
|
|
@@ -35,7 +14,7 @@ export class StatusItem {
|
|
|
35
14
|
payload.id = this.options.id;
|
|
36
15
|
}
|
|
37
16
|
if (this.options.icon !== undefined) {
|
|
38
|
-
payload.icon = this.options.icon
|
|
17
|
+
payload.icon = this.options.icon;
|
|
39
18
|
}
|
|
40
19
|
if (this.options.label !== undefined) {
|
|
41
20
|
payload.label = this.options.label;
|