glimpse-sdk 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,18 @@ Requires Node.js 20+.
10
10
  npm install glimpse-sdk
11
11
  ```
12
12
 
13
+ ## Develop
14
+
15
+ Create and live-run a TypeScript applet project with the Glimpse tooling:
16
+
17
+ ```sh
18
+ glimpse-applet new counter --lang typescript
19
+ cd counter
20
+ glimpse-applet dev
21
+ ```
22
+
23
+ Read `docs/custom-applets/tooling.md` for project layout, `applet.toml`, dev applets, linking, and diagnostics.
24
+
13
25
  ## Goals
14
26
 
15
27
  - typed protocol models
@@ -92,3 +104,23 @@ Use explicit registration helpers instead of decorators:
92
104
 
93
105
  The SDK owns the line transport. `status(state)` produces the panel items;
94
106
  `popover(state)` produces the popover tree; both are pure functions of state.
107
+
108
+ ## IPC client
109
+
110
+ Talk to a running Glimpse daemon: subscribe to event channels and dispatch
111
+ actions. `ipc(service)` only resolves the socket path — the connection is
112
+ opened lazily.
113
+
114
+ ```ts
115
+ import { ipc } from "glimpse-sdk";
116
+
117
+ const sub = ipc("shell"); // "shell" | "wallpaper" | "idle" | "lock"
118
+
119
+ // Fire an action; awaits the ack, throws IpcError if the server rejects it.
120
+ const ack = await sub.dispatch("open_uri", { uri: "https://example.com" });
121
+
122
+ // Stream events until the socket closes.
123
+ for await (const ev of sub.listen("audio.*")) {
124
+ console.log(ev.name, ev.fields);
125
+ }
126
+ ```
package/dist/src/app.d.ts CHANGED
@@ -7,6 +7,20 @@ interface OutgoingMessage {
7
7
  data: unknown;
8
8
  line: string;
9
9
  }
10
+ interface ShowNotificationArgs {
11
+ summary: string;
12
+ body?: string;
13
+ urgency?: string;
14
+ }
15
+ interface OpenUriArgs {
16
+ uri: string;
17
+ }
18
+ interface CopyToClipboardArgs {
19
+ text: string;
20
+ }
21
+ interface DismissNotificationArgs {
22
+ id: number;
23
+ }
10
24
  export declare abstract class Applet<State extends object> {
11
25
  state: State;
12
26
  private readonly handlerMap;
@@ -30,6 +44,11 @@ export declare abstract class Applet<State extends object> {
30
44
  onChange(id: string, handler: Handler<ChangeEvent>): void;
31
45
  onToggle(id: string, handler: Handler<ToggleEvent>): void;
32
46
  isPopoverOpen(): boolean;
47
+ protected showNotification(args: ShowNotificationArgs): void;
48
+ protected openUri(args: OpenUriArgs): void;
49
+ protected copyToClipboard(args: CopyToClipboardArgs): void;
50
+ protected dismissNotification(args: DismissNotificationArgs): void;
51
+ protected closePopover(): void;
33
52
  run(): Promise<void>;
34
53
  protected drainOutgoingForTest(): Promise<OutgoingMessage[]>;
35
54
  private register;
@@ -38,5 +57,6 @@ export declare abstract class Applet<State extends object> {
38
57
  private scheduleRender;
39
58
  private flushRender;
40
59
  private emit;
60
+ private emitAction;
41
61
  }
42
62
  export {};
package/dist/src/app.js CHANGED
@@ -43,6 +43,21 @@ export class Applet {
43
43
  isPopoverOpen() {
44
44
  return this.popoverOpen;
45
45
  }
46
+ showNotification(args) {
47
+ this.emitAction("show_notification", args);
48
+ }
49
+ openUri(args) {
50
+ this.emitAction("open_uri", args);
51
+ }
52
+ copyToClipboard(args) {
53
+ this.emitAction("copy_to_clipboard", args);
54
+ }
55
+ dismissNotification(args) {
56
+ this.emitAction("dismiss_notification", args);
57
+ }
58
+ closePopover() {
59
+ this.emitAction("close_popover", {});
60
+ }
46
61
  async run() {
47
62
  process.stdout.on("error", (err) => {
48
63
  if (err.code === "EPIPE") {
@@ -137,9 +152,7 @@ export class Applet {
137
152
  }
138
153
  const widget = await this.popover(this.state);
139
154
  const tree = { root: widget?.toProtocol() ?? null };
140
- // Emit only when popover open, or first render, or clearing.
141
- const shouldEmit = this.popoverOpen || this.lastTree === null || tree.root === null;
142
- if (shouldEmit && !deepEqual(tree, this.lastTree)) {
155
+ if (!deepEqual(tree, this.lastTree)) {
143
156
  this.lastTree = tree;
144
157
  this.emit("popover", tree);
145
158
  }
@@ -157,6 +170,9 @@ export class Applet {
157
170
  }
158
171
  }
159
172
  }
173
+ emitAction(type, args) {
174
+ this.emit("action", { type, arguments: args });
175
+ }
160
176
  }
161
177
  function parseLine(line) {
162
178
  const trimmed = line.trim();
@@ -1,4 +1,5 @@
1
1
  export { Applet } from "./app.js";
2
2
  export { type CallbackEvent, type ChangeEvent, type ClickEvent, type InitEvent, type InputEvent, type PopoverEvent, type ScrollEvent, type ToggleEvent, parseCallbackEvent, parseInitEvent, } from "./events.js";
3
3
  export { Icon, StatusItem } from "./protocol.js";
4
+ export { type Event, IpcError, Subscriber, ipc } from "./ipc.js";
4
5
  export { ActionItem, type Align, Badge, Box, Button, type ButtonVariant, Card, Checkbox, Column, type ContentFit, Copyable, EmptyState, Expander, Grid, GridChild, Hero, Image, Item, Label, LevelBar, type LevelBarMode, LinkButton, ListBox, MenuButton, Meter, Overlay, type Orientation, type PagerAppearance, PagerItem, PagerStrip, Picture, Progress, type Properties, PropertyList, Row, Scroll, Section, Select, SelectOption, Separator, Slider, Spinner, StatusDot, Switch, ToggleButton, TreeExpander, type TreeNode, type Variant, } from "./widgets.js";
package/dist/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { Applet } from "./app.js";
2
2
  export { parseCallbackEvent, parseInitEvent, } from "./events.js";
3
3
  export { Icon, StatusItem } from "./protocol.js";
4
+ export { IpcError, Subscriber, ipc } from "./ipc.js";
4
5
  export { ActionItem, Badge, Box, Button, Card, Checkbox, Column, Copyable, EmptyState, Expander, Grid, GridChild, Hero, Image, Item, Label, LevelBar, LinkButton, ListBox, MenuButton, Meter, Overlay, PagerItem, PagerStrip, Picture, Progress, PropertyList, Row, Scroll, Section, Select, SelectOption, Separator, Slider, Spinner, StatusDot, Switch, ToggleButton, TreeExpander, } from "./widgets.js";
@@ -0,0 +1,28 @@
1
+ /** One decoded event line; `fields` values are unescaped. */
2
+ export interface Event {
3
+ name: string;
4
+ ts: number;
5
+ fields: Record<string, string>;
6
+ }
7
+ /** Thrown on connection failure or a rejected dispatch (`ok=false`). */
8
+ export declare class IpcError extends Error {
9
+ }
10
+ /** A resolved IPC endpoint. Cheap to create; holds only the socket path. */
11
+ export declare class Subscriber {
12
+ private readonly socketPath;
13
+ constructor(socketPath: string);
14
+ /** Subscribe to `channel` (an exact name, `prefix.*`, or `*`) and yield
15
+ * events until the server closes the connection. */
16
+ listen(channel: string): AsyncGenerator<Event>;
17
+ /** Dispatch `action` with `params` on a fresh connection and await the
18
+ * ack. Resolves with the extra ack fields; rejects with `IpcError` if the
19
+ * server replies `ok=false`. */
20
+ dispatch(action: string, params?: Record<string, string>): Promise<Record<string, string>>;
21
+ private connect;
22
+ }
23
+ /** Resolve the {@link Subscriber} for `service` (`"shell"` for the panel).
24
+ *
25
+ * The socket is `<dir>/<service>.sock` (`shell` maps to `ipc.sock`) where
26
+ * `<dir>` is `$GLIMPSE_IPC_DIR`, else `$XDG_RUNTIME_DIR/glimpse`. No
27
+ * connection is made here. */
28
+ export declare function ipc(service?: string): Subscriber;
@@ -0,0 +1,214 @@
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
+ import { createConnection } from "node:net";
9
+ import { createInterface } from "node:readline";
10
+ import { join } from "node:path";
11
+ /** Thrown on connection failure or a rejected dispatch (`ok=false`). */
12
+ export class IpcError extends Error {
13
+ }
14
+ /** A resolved IPC endpoint. Cheap to create; holds only the socket path. */
15
+ export class Subscriber {
16
+ socketPath;
17
+ constructor(socketPath) {
18
+ this.socketPath = socketPath;
19
+ }
20
+ /** Subscribe to `channel` (an exact name, `prefix.*`, or `*`) and yield
21
+ * events until the server closes the connection. */
22
+ async *listen(channel) {
23
+ const conn = await this.connect();
24
+ try {
25
+ conn.socket.write(`subscribe ${channel}\n`);
26
+ for (;;) {
27
+ const next = await conn.lines.next();
28
+ if (next.done)
29
+ return;
30
+ const line = next.value.trim();
31
+ if (line)
32
+ yield parseEvent(line);
33
+ }
34
+ }
35
+ finally {
36
+ close(conn);
37
+ }
38
+ }
39
+ /** Dispatch `action` with `params` on a fresh connection and await the
40
+ * ack. Resolves with the extra ack fields; rejects with `IpcError` if the
41
+ * server replies `ok=false`. */
42
+ async dispatch(action, params = {}) {
43
+ validateToken("action", action, false);
44
+ const entries = Object.entries(params);
45
+ for (const [k] of entries)
46
+ validateToken("param key", k, true);
47
+ const conn = await this.connect();
48
+ try {
49
+ let line = action;
50
+ for (const [k, v] of entries) {
51
+ line += ` ${k}=${escape(v)}`;
52
+ }
53
+ conn.socket.write(line + "\n");
54
+ const next = await conn.lines.next();
55
+ if (next.done) {
56
+ throw new IpcError("IPC server closed connection without ack");
57
+ }
58
+ return parseAck(next.value.trim());
59
+ }
60
+ finally {
61
+ close(conn);
62
+ }
63
+ }
64
+ connect() {
65
+ return new Promise((resolve, reject) => {
66
+ const socket = createConnection({ path: this.socketPath });
67
+ let connected = false;
68
+ // This handler stays attached for the socket's whole lifetime. Never
69
+ // remove it: an unhandled 'error' event on a Socket throws and crashes
70
+ // the process. `close()` only destroys the socket, it does not detach.
71
+ socket.on("error", (err) => {
72
+ if (connected) {
73
+ // Post-connect transport error: end the stream cleanly so the
74
+ // line iterator completes instead of crashing the process.
75
+ socket.destroy();
76
+ }
77
+ else {
78
+ reject(new IpcError(`cannot connect to IPC socket at ${this.socketPath}: ${err.message}`));
79
+ }
80
+ });
81
+ socket.once("connect", () => {
82
+ connected = true;
83
+ const rl = createInterface({ input: socket, crlfDelay: Infinity });
84
+ const lines = rl[Symbol.asyncIterator]();
85
+ void (async () => {
86
+ const hello = await lines.next();
87
+ if (hello.done || !hello.value.startsWith("hello")) {
88
+ rl.close();
89
+ socket.destroy();
90
+ reject(new IpcError(hello.done
91
+ ? "IPC server closed connection before hello"
92
+ : `unexpected IPC greeting: ${hello.value}`));
93
+ return;
94
+ }
95
+ resolve({ socket, rl, lines });
96
+ })();
97
+ });
98
+ });
99
+ }
100
+ }
101
+ /** Resolve the {@link Subscriber} for `service` (`"shell"` for the panel).
102
+ *
103
+ * The socket is `<dir>/<service>.sock` (`shell` maps to `ipc.sock`) where
104
+ * `<dir>` is `$GLIMPSE_IPC_DIR`, else `$XDG_RUNTIME_DIR/glimpse`. No
105
+ * connection is made here. */
106
+ export function ipc(service = "shell") {
107
+ return new Subscriber(socketPath(service));
108
+ }
109
+ function socketPath(service) {
110
+ const override = process.env.GLIMPSE_IPC_DIR;
111
+ let dir;
112
+ if (override) {
113
+ dir = override;
114
+ }
115
+ else {
116
+ const runtime = process.env.XDG_RUNTIME_DIR;
117
+ if (!runtime) {
118
+ throw new IpcError("neither GLIMPSE_IPC_DIR nor XDG_RUNTIME_DIR is set; " +
119
+ "cannot locate the Glimpse IPC socket");
120
+ }
121
+ dir = join(runtime, "glimpse");
122
+ }
123
+ return join(dir, service === "shell" ? "ipc.sock" : `${service}.sock`);
124
+ }
125
+ function close(conn) {
126
+ conn.rl.close();
127
+ conn.socket.destroy();
128
+ }
129
+ // The wire protocol splits client lines on whitespace and never unescapes
130
+ // the command name or a field key, so an `action`/key with whitespace would
131
+ // forge extra tokens or whole client lines. Values are safe (escaped).
132
+ function validateToken(label, token, forbidEq) {
133
+ if (token.length === 0) {
134
+ throw new IpcError(`IPC ${label} must not be empty`);
135
+ }
136
+ if (/\s/.test(token)) {
137
+ throw new IpcError(`IPC ${label} "${token}" must not contain whitespace`);
138
+ }
139
+ if (forbidEq && token.includes("=")) {
140
+ throw new IpcError(`IPC param key "${token}" must not contain '='`);
141
+ }
142
+ }
143
+ function escape(s) {
144
+ return s
145
+ .replaceAll("\\", "\\\\")
146
+ .replaceAll("\n", "\\n")
147
+ .replaceAll("\t", "\\t")
148
+ .replaceAll(" ", "\\s");
149
+ }
150
+ function unescape(s) {
151
+ let out = "";
152
+ for (let i = 0; i < s.length; i++) {
153
+ if (s[i] !== "\\") {
154
+ out += s[i];
155
+ continue;
156
+ }
157
+ const next = s[++i];
158
+ if (next === "s")
159
+ out += " ";
160
+ else if (next === "n")
161
+ out += "\n";
162
+ else if (next === "t")
163
+ out += "\t";
164
+ else if (next === "\\")
165
+ out += "\\";
166
+ else if (next === undefined)
167
+ out += "\\";
168
+ else
169
+ out += "\\" + next;
170
+ }
171
+ return out;
172
+ }
173
+ function parseEvent(line) {
174
+ const tokens = line.split(/\s+/);
175
+ const name = tokens[0] ?? "";
176
+ let ts = 0;
177
+ const fields = {};
178
+ for (const token of tokens.slice(1)) {
179
+ const eq = token.indexOf("=");
180
+ if (eq < 0)
181
+ continue;
182
+ const key = token.slice(0, eq);
183
+ const value = unescape(token.slice(eq + 1));
184
+ if (key === "ts" && /^\d+$/.test(value)) {
185
+ ts = Number(value);
186
+ continue;
187
+ }
188
+ fields[key] = value;
189
+ }
190
+ return { name, ts, fields };
191
+ }
192
+ function parseAck(line) {
193
+ const tokens = line.split(/\s+/);
194
+ if (tokens[0] !== "ack") {
195
+ throw new IpcError(`expected an ack, got: ${line}`);
196
+ }
197
+ let ok = false;
198
+ const fields = {};
199
+ for (const token of tokens.slice(1)) {
200
+ const eq = token.indexOf("=");
201
+ if (eq < 0)
202
+ continue;
203
+ const key = token.slice(0, eq);
204
+ const value = unescape(token.slice(eq + 1));
205
+ if (key === "ok")
206
+ ok = value === "true";
207
+ else
208
+ fields[key] = value;
209
+ }
210
+ if (!ok) {
211
+ throw new IpcError(fields.error ?? "command failed");
212
+ }
213
+ return fields;
214
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glimpse-sdk",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Typed async framework for building Glimpse exec applets",
5
5
  "type": "module",
6
6
  "main": "dist/src/index.js",
package/src/app.ts CHANGED
@@ -23,6 +23,24 @@ interface OutgoingMessage {
23
23
  line: string;
24
24
  }
25
25
 
26
+ interface ShowNotificationArgs {
27
+ summary: string;
28
+ body?: string;
29
+ urgency?: string;
30
+ }
31
+
32
+ interface OpenUriArgs {
33
+ uri: string;
34
+ }
35
+
36
+ interface CopyToClipboardArgs {
37
+ text: string;
38
+ }
39
+
40
+ interface DismissNotificationArgs {
41
+ id: number;
42
+ }
43
+
26
44
  export abstract class Applet<State extends object> {
27
45
  state: State;
28
46
 
@@ -83,6 +101,26 @@ export abstract class Applet<State extends object> {
83
101
  return this.popoverOpen;
84
102
  }
85
103
 
104
+ protected showNotification(args: ShowNotificationArgs): void {
105
+ this.emitAction("show_notification", args);
106
+ }
107
+
108
+ protected openUri(args: OpenUriArgs): void {
109
+ this.emitAction("open_uri", args);
110
+ }
111
+
112
+ protected copyToClipboard(args: CopyToClipboardArgs): void {
113
+ this.emitAction("copy_to_clipboard", args);
114
+ }
115
+
116
+ protected dismissNotification(args: DismissNotificationArgs): void {
117
+ this.emitAction("dismiss_notification", args);
118
+ }
119
+
120
+ protected closePopover(): void {
121
+ this.emitAction("close_popover", {});
122
+ }
123
+
86
124
  async run(): Promise<void> {
87
125
  process.stdout.on("error", (err: NodeJS.ErrnoException) => {
88
126
  if (err.code === "EPIPE") {
@@ -184,9 +222,7 @@ export abstract class Applet<State extends object> {
184
222
 
185
223
  const widget = await this.popover(this.state);
186
224
  const tree = { root: widget?.toProtocol() ?? null };
187
- // Emit only when popover open, or first render, or clearing.
188
- const shouldEmit = this.popoverOpen || this.lastTree === null || tree.root === null;
189
- if (shouldEmit && !deepEqual(tree, this.lastTree)) {
225
+ if (!deepEqual(tree, this.lastTree)) {
190
226
  this.lastTree = tree;
191
227
  this.emit("popover", tree);
192
228
  }
@@ -204,6 +240,10 @@ export abstract class Applet<State extends object> {
204
240
  }
205
241
  }
206
242
  }
243
+
244
+ private emitAction(type: string, args: object): void {
245
+ this.emit("action", { type, arguments: args });
246
+ }
207
247
  }
208
248
 
209
249
  function parseLine(line: string): { command: string; data: unknown } | null {
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export {
12
12
  parseInitEvent,
13
13
  } from "./events.js";
14
14
  export { Icon, StatusItem } from "./protocol.js";
15
+ export { type Event, IpcError, Subscriber, ipc } from "./ipc.js";
15
16
  export {
16
17
  ActionItem,
17
18
  type Align,
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
+ }