pi-smart-voice-notify 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/package.json +61 -61
- package/src/focus-detect.ts +474 -389
- package/src/index.test.ts +320 -5
- package/src/index.ts +437 -78
- package/src/notify-audio.ts +40 -27
- package/src/tts.ts +23 -15
package/src/focus-detect.ts
CHANGED
|
@@ -1,389 +1,474 @@
|
|
|
1
|
-
import { exec, type ExecOptionsWithStringEncoding } from "node:child_process";
|
|
2
|
-
import { promisify } from "node:util";
|
|
3
|
-
import { getErrorMessage } from "./logging.ts";
|
|
4
|
-
|
|
5
|
-
export type LinuxSessionType = "x11" | "wayland" | "unknown";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"gnome-terminal
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
if (!
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
if (
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
}
|
|
1
|
+
import { exec, type ExecOptionsWithStringEncoding } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { getErrorMessage } from "./logging.ts";
|
|
4
|
+
|
|
5
|
+
export type LinuxSessionType = "x11" | "wayland" | "unknown";
|
|
6
|
+
type FocusSessionType = LinuxSessionType | "windows" | "unsupported";
|
|
7
|
+
|
|
8
|
+
export interface FocusDetectOptions {
|
|
9
|
+
debug?: boolean;
|
|
10
|
+
cacheTtlMs?: number;
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
logger?: (message: string, details?: Record<string, unknown>) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface FocusCacheState {
|
|
16
|
+
isFocused: boolean;
|
|
17
|
+
timestamp: number;
|
|
18
|
+
focusedWindow: string | null;
|
|
19
|
+
sessionType: FocusSessionType;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface SwayTreeNode {
|
|
23
|
+
focused?: boolean;
|
|
24
|
+
name?: string;
|
|
25
|
+
app_id?: string;
|
|
26
|
+
window_properties?: {
|
|
27
|
+
class?: string;
|
|
28
|
+
instance?: string;
|
|
29
|
+
title?: string;
|
|
30
|
+
};
|
|
31
|
+
nodes?: SwayTreeNode[];
|
|
32
|
+
floating_nodes?: SwayTreeNode[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type WaylandDesktop = "sway" | "gnome" | "unknown";
|
|
36
|
+
|
|
37
|
+
type ExecAsync = (
|
|
38
|
+
command: string,
|
|
39
|
+
options?: ExecOptionsWithStringEncoding,
|
|
40
|
+
) => Promise<{ stdout: string; stderr: string }>;
|
|
41
|
+
|
|
42
|
+
const execAsync = promisify(exec) as ExecAsync;
|
|
43
|
+
|
|
44
|
+
const DEFAULT_TIMEOUT_MS = 1500;
|
|
45
|
+
const DEFAULT_CACHE_TTL_MS = 400;
|
|
46
|
+
const DEFAULT_MAX_BUFFER = 1024 * 1024;
|
|
47
|
+
|
|
48
|
+
const LINUX_TERMINAL_IDENTIFIERS = [
|
|
49
|
+
"wezterm",
|
|
50
|
+
"org.wezfurlong.wezterm",
|
|
51
|
+
"alacritty",
|
|
52
|
+
"kitty",
|
|
53
|
+
"gnome-terminal",
|
|
54
|
+
"gnome-terminal-server",
|
|
55
|
+
"xfce4-terminal",
|
|
56
|
+
"xfce terminal",
|
|
57
|
+
"konsole",
|
|
58
|
+
"tilix",
|
|
59
|
+
"terminator",
|
|
60
|
+
"xterm",
|
|
61
|
+
"urxvt",
|
|
62
|
+
"rxvt",
|
|
63
|
+
"foot",
|
|
64
|
+
"st",
|
|
65
|
+
"mate-terminal",
|
|
66
|
+
"lxterminal",
|
|
67
|
+
"kgx",
|
|
68
|
+
"gnome console",
|
|
69
|
+
] as const;
|
|
70
|
+
|
|
71
|
+
const WINDOWS_TERMINAL_IDENTIFIERS = [
|
|
72
|
+
"windowsterminal",
|
|
73
|
+
"windows terminal",
|
|
74
|
+
"openconsole",
|
|
75
|
+
"conhost",
|
|
76
|
+
"cmd",
|
|
77
|
+
"command prompt",
|
|
78
|
+
"powershell",
|
|
79
|
+
"pwsh",
|
|
80
|
+
"bash",
|
|
81
|
+
"git bash",
|
|
82
|
+
"mintty",
|
|
83
|
+
"wezterm",
|
|
84
|
+
"wezterm-gui",
|
|
85
|
+
"alacritty",
|
|
86
|
+
"kitty",
|
|
87
|
+
"tabby",
|
|
88
|
+
"warp",
|
|
89
|
+
"rio",
|
|
90
|
+
"ghostty",
|
|
91
|
+
"hyper",
|
|
92
|
+
] as const;
|
|
93
|
+
|
|
94
|
+
const POWERSHELL_GET_FRONTMOST_PROCESS = `
|
|
95
|
+
Add-Type @"
|
|
96
|
+
using System;
|
|
97
|
+
using System.Runtime.InteropServices;
|
|
98
|
+
|
|
99
|
+
public static class Win32FocusDetect {
|
|
100
|
+
[DllImport("user32.dll")]
|
|
101
|
+
public static extern IntPtr GetForegroundWindow();
|
|
102
|
+
|
|
103
|
+
[DllImport("user32.dll")]
|
|
104
|
+
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out int processId);
|
|
105
|
+
|
|
106
|
+
[DllImport("user32.dll")]
|
|
107
|
+
[return: MarshalAs(UnmanagedType.Bool)]
|
|
108
|
+
public static extern bool IsIconic(IntPtr hWnd);
|
|
109
|
+
|
|
110
|
+
[DllImport("user32.dll")]
|
|
111
|
+
[return: MarshalAs(UnmanagedType.Bool)]
|
|
112
|
+
public static extern bool IsWindowVisible(IntPtr hWnd);
|
|
113
|
+
}
|
|
114
|
+
"@
|
|
115
|
+
|
|
116
|
+
$processId = 0
|
|
117
|
+
$foregroundWindow = [Win32FocusDetect]::GetForegroundWindow()
|
|
118
|
+
|
|
119
|
+
if ($foregroundWindow -eq [IntPtr]::Zero) {
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if ([Win32FocusDetect]::IsIconic($foregroundWindow)) {
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (-not [Win32FocusDetect]::IsWindowVisible($foregroundWindow)) {
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
[Win32FocusDetect]::GetWindowThreadProcessId($foregroundWindow, [ref]$processId) | Out-Null
|
|
132
|
+
if ($processId -le 0) {
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
Get-Process -Id $processId | Select-Object -ExpandProperty ProcessName
|
|
137
|
+
`;
|
|
138
|
+
|
|
139
|
+
let focusCache: FocusCacheState = {
|
|
140
|
+
isFocused: false,
|
|
141
|
+
timestamp: 0,
|
|
142
|
+
focusedWindow: null,
|
|
143
|
+
sessionType: "unknown",
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
function emitLog(
|
|
147
|
+
level: "debug" | "error",
|
|
148
|
+
message: string,
|
|
149
|
+
options: FocusDetectOptions,
|
|
150
|
+
details: Record<string, unknown> = {},
|
|
151
|
+
): void {
|
|
152
|
+
const logger = options.logger;
|
|
153
|
+
if (!logger) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (level === "debug" && !options.debug) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
logger(message, details);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function detectLinuxSessionType(env: NodeJS.ProcessEnv = process.env): LinuxSessionType {
|
|
165
|
+
const explicit = env.XDG_SESSION_TYPE?.toLowerCase().trim();
|
|
166
|
+
if (explicit === "x11" || explicit === "wayland") {
|
|
167
|
+
return explicit;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (env.WAYLAND_DISPLAY) {
|
|
171
|
+
return "wayland";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (env.DISPLAY) {
|
|
175
|
+
return "x11";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return "unknown";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function detectWaylandDesktop(env: NodeJS.ProcessEnv = process.env): WaylandDesktop {
|
|
182
|
+
const desktop = [env.XDG_CURRENT_DESKTOP, env.XDG_SESSION_DESKTOP, env.DESKTOP_SESSION]
|
|
183
|
+
.filter(Boolean)
|
|
184
|
+
.join(":")
|
|
185
|
+
.toLowerCase();
|
|
186
|
+
|
|
187
|
+
if (desktop.includes("sway") || Boolean(env.SWAYSOCK)) {
|
|
188
|
+
return "sway";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (desktop.includes("gnome")) {
|
|
192
|
+
return "gnome";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return "unknown";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function runCommand(
|
|
199
|
+
command: string,
|
|
200
|
+
label: string,
|
|
201
|
+
options: FocusDetectOptions,
|
|
202
|
+
maxBuffer = DEFAULT_MAX_BUFFER,
|
|
203
|
+
): Promise<string | null> {
|
|
204
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
208
|
+
encoding: "utf8",
|
|
209
|
+
timeout: timeoutMs,
|
|
210
|
+
maxBuffer,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (stderr.trim()) {
|
|
214
|
+
emitLog("debug", `${label}: stderr`, options, { stderr: stderr.trim() });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const output = stdout.trim();
|
|
218
|
+
if (!output) {
|
|
219
|
+
emitLog("debug", `${label}: empty output`, options);
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return output;
|
|
224
|
+
} catch (error) {
|
|
225
|
+
emitLog("error", `${label}: command failed`, options, {
|
|
226
|
+
command,
|
|
227
|
+
error: getErrorMessage(error),
|
|
228
|
+
});
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function parseQuotedValues(text: string): string[] {
|
|
234
|
+
const matches = text.match(/"([^"\\]*(?:\\.[^"\\]*)*)"/g) ?? [];
|
|
235
|
+
return matches
|
|
236
|
+
.map((value) => value.slice(1, -1).trim())
|
|
237
|
+
.filter((value) => value.length > 0);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function getEncodedPowerShellScript(script: string): string {
|
|
241
|
+
return Buffer.from(script, "utf16le").toString("base64");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function getFocusedWindowWindows(options: FocusDetectOptions): Promise<string | null> {
|
|
245
|
+
const encodedScript = getEncodedPowerShellScript(POWERSHELL_GET_FRONTMOST_PROCESS);
|
|
246
|
+
const command = `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodedScript}`;
|
|
247
|
+
return runCommand(command, "windows.powershell.frontmost_process", options, 1024);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function getFocusedWindowX11(options: FocusDetectOptions): Promise<string | null> {
|
|
251
|
+
const activeWindow = await runCommand("xdotool getactivewindow", "x11.xdotool.getactivewindow", options);
|
|
252
|
+
if (!activeWindow) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const windowId = activeWindow.split(/\s+/)[0]?.trim();
|
|
257
|
+
if (!windowId) {
|
|
258
|
+
emitLog("error", "x11.xdotool returned empty window id", options);
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const windowProps = await runCommand(
|
|
263
|
+
`xprop -id ${windowId} WM_CLASS WM_NAME _NET_WM_NAME`,
|
|
264
|
+
"x11.xprop.window",
|
|
265
|
+
options,
|
|
266
|
+
);
|
|
267
|
+
if (!windowProps) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const quotedValues = parseQuotedValues(windowProps);
|
|
272
|
+
if (quotedValues.length > 0) {
|
|
273
|
+
return quotedValues.join(" ");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return windowProps;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function findFocusedSwayNode(node: SwayTreeNode): SwayTreeNode | null {
|
|
280
|
+
if (node.focused) {
|
|
281
|
+
return node;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const children = [...(node.nodes ?? []), ...(node.floating_nodes ?? [])];
|
|
285
|
+
for (const child of children) {
|
|
286
|
+
const focusedChild = findFocusedSwayNode(child);
|
|
287
|
+
if (focusedChild) {
|
|
288
|
+
return focusedChild;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function getFocusedWindowWaylandSway(options: FocusDetectOptions): Promise<string | null> {
|
|
296
|
+
const treeOutput = await runCommand("swaymsg -t get_tree", "wayland.sway.get_tree", options, 8 * 1024 * 1024);
|
|
297
|
+
if (!treeOutput) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const tree = JSON.parse(treeOutput) as SwayTreeNode;
|
|
303
|
+
const focused = findFocusedSwayNode(tree);
|
|
304
|
+
if (!focused) {
|
|
305
|
+
emitLog("debug", "wayland.sway focused node not found", options);
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return (
|
|
310
|
+
focused.app_id ??
|
|
311
|
+
focused.window_properties?.class ??
|
|
312
|
+
focused.window_properties?.instance ??
|
|
313
|
+
focused.window_properties?.title ??
|
|
314
|
+
focused.name ??
|
|
315
|
+
null
|
|
316
|
+
);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
emitLog("error", "wayland.sway failed to parse sway tree", options, {
|
|
319
|
+
error: getErrorMessage(error),
|
|
320
|
+
});
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function parseGnomeEvalResult(output: string): string | null {
|
|
326
|
+
const tupleMatch = output.trim().match(/^\((true|false),\s*(.*)\)$/s);
|
|
327
|
+
if (!tupleMatch) {
|
|
328
|
+
return output.trim() || null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (tupleMatch[1] !== "true") {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let value = tupleMatch[2]?.trim() ?? "";
|
|
336
|
+
if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"'))) {
|
|
337
|
+
value = value.slice(1, -1);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const unescaped = value.replace(/\\'/g, "'").replace(/\\"/g, '"').trim();
|
|
341
|
+
return unescaped.length > 0 ? unescaped : null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function getFocusedWindowWaylandGnome(options: FocusDetectOptions): Promise<string | null> {
|
|
345
|
+
const command =
|
|
346
|
+
"gdbus call --session --dest org.gnome.Shell --object-path /org/gnome/Shell --method org.gnome.Shell.Eval '(() => { const w = global.display.focus_window; if (!w) return \"\"; return [w.get_wm_class_instance && w.get_wm_class_instance(), w.get_wm_class && w.get_wm_class(), w.get_title && w.get_title()].filter(Boolean).join(\" \"); })()'";
|
|
347
|
+
|
|
348
|
+
const evalOutput = await runCommand(command, "wayland.gnome.gdbus", options);
|
|
349
|
+
if (!evalOutput) {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return parseGnomeEvalResult(evalOutput);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function getFocusedWindowWayland(options: FocusDetectOptions): Promise<string | null> {
|
|
357
|
+
const desktop = detectWaylandDesktop();
|
|
358
|
+
emitLog("debug", "wayland.desktop.detected", options, { desktop });
|
|
359
|
+
|
|
360
|
+
if (desktop === "sway") {
|
|
361
|
+
const swayFocused = await getFocusedWindowWaylandSway(options);
|
|
362
|
+
if (swayFocused) {
|
|
363
|
+
return swayFocused;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (desktop === "gnome") {
|
|
368
|
+
const gnomeFocused = await getFocusedWindowWaylandGnome(options);
|
|
369
|
+
if (gnomeFocused) {
|
|
370
|
+
return gnomeFocused;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const swayFocused = await getFocusedWindowWaylandSway(options);
|
|
375
|
+
if (swayFocused) {
|
|
376
|
+
return swayFocused;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return getFocusedWindowWaylandGnome(options);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function normalize(value: string): string {
|
|
383
|
+
return value.toLowerCase().replace(/\.exe$/i, "").trim();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function isKnownTerminalWindow(value: string | null, identifiers: readonly string[]): boolean {
|
|
387
|
+
if (!value) {
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const normalized = normalize(value);
|
|
392
|
+
return identifiers.some((identifier) => normalized.includes(normalize(identifier)));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function clearFocusDetectCache(): void {
|
|
396
|
+
focusCache = {
|
|
397
|
+
isFocused: false,
|
|
398
|
+
timestamp: 0,
|
|
399
|
+
focusedWindow: null,
|
|
400
|
+
sessionType: "unknown",
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function getFocusDetectCacheState(): FocusCacheState {
|
|
405
|
+
return { ...focusCache };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export async function isTerminalFocused(options: FocusDetectOptions = {}): Promise<boolean> {
|
|
409
|
+
const now = Date.now();
|
|
410
|
+
const cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
411
|
+
|
|
412
|
+
if (now - focusCache.timestamp < cacheTtlMs) {
|
|
413
|
+
emitLog("debug", "cache.hit", options, {
|
|
414
|
+
isFocused: focusCache.isFocused,
|
|
415
|
+
focusedWindow: focusCache.focusedWindow,
|
|
416
|
+
sessionType: focusCache.sessionType,
|
|
417
|
+
});
|
|
418
|
+
return focusCache.isFocused;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
let focusedWindow: string | null = null;
|
|
422
|
+
let sessionType: FocusSessionType = "unsupported";
|
|
423
|
+
let isFocused = false;
|
|
424
|
+
|
|
425
|
+
if (process.platform === "win32") {
|
|
426
|
+
sessionType = "windows";
|
|
427
|
+
emitLog("debug", "platform.detected", options, { sessionType });
|
|
428
|
+
focusedWindow = await getFocusedWindowWindows(options);
|
|
429
|
+
isFocused = isKnownTerminalWindow(focusedWindow, WINDOWS_TERMINAL_IDENTIFIERS);
|
|
430
|
+
} else if (process.platform === "linux") {
|
|
431
|
+
sessionType = detectLinuxSessionType();
|
|
432
|
+
emitLog("debug", "session.detected", options, { sessionType });
|
|
433
|
+
|
|
434
|
+
if (sessionType === "x11") {
|
|
435
|
+
focusedWindow = await getFocusedWindowX11(options);
|
|
436
|
+
} else if (sessionType === "wayland") {
|
|
437
|
+
focusedWindow = await getFocusedWindowWayland(options);
|
|
438
|
+
} else {
|
|
439
|
+
emitLog("error", "unable to determine linux session type", options, {
|
|
440
|
+
XDG_SESSION_TYPE: process.env.XDG_SESSION_TYPE ?? null,
|
|
441
|
+
WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY ?? null,
|
|
442
|
+
DISPLAY: process.env.DISPLAY ?? null,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
isFocused = isKnownTerminalWindow(focusedWindow, LINUX_TERMINAL_IDENTIFIERS);
|
|
447
|
+
} else {
|
|
448
|
+
emitLog("debug", "platform.unsupported", options, {
|
|
449
|
+
platform: process.platform,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
focusCache = {
|
|
454
|
+
isFocused,
|
|
455
|
+
timestamp: now,
|
|
456
|
+
focusedWindow,
|
|
457
|
+
sessionType,
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
emitLog("debug", "focus.result", options, {
|
|
461
|
+
isFocused,
|
|
462
|
+
focusedWindow,
|
|
463
|
+
sessionType,
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
return isFocused;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export default {
|
|
470
|
+
isTerminalFocused,
|
|
471
|
+
detectLinuxSessionType,
|
|
472
|
+
clearFocusDetectCache,
|
|
473
|
+
getFocusDetectCacheState,
|
|
474
|
+
};
|