pi-smart-voice-notify 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/CHANGELOG.md +8 -0
- package/LICENSE +21 -0
- package/README.md +188 -0
- package/assets/Machine-alert-beep-sound-effect.mp3 +0 -0
- package/assets/Soft-high-tech-notification-sound-effect.mp3 +0 -0
- package/assets/pi-smart-voice-notify.png +0 -0
- package/config/config.example.json +30 -0
- package/index.ts +3 -0
- package/package.json +53 -0
- package/src/config-store.ts +308 -0
- package/src/desktop-notify.ts +177 -0
- package/src/index.ts +860 -0
- package/src/logging.ts +73 -0
- package/src/notify-audio.ts +414 -0
- package/src/types.ts +52 -0
- package/src/zellij-modal.ts +999 -0
package/src/logging.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export function getErrorMessage(error: unknown): string {
|
|
4
|
+
if (error instanceof Error) {
|
|
5
|
+
return error.message;
|
|
6
|
+
}
|
|
7
|
+
return String(error);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function safeJsonStringify(value: unknown): string {
|
|
11
|
+
const seen = new WeakSet<object>();
|
|
12
|
+
return JSON.stringify(value, (_key, currentValue) => {
|
|
13
|
+
if (currentValue instanceof Error) {
|
|
14
|
+
return {
|
|
15
|
+
name: currentValue.name,
|
|
16
|
+
message: currentValue.message,
|
|
17
|
+
stack: currentValue.stack,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
if (typeof currentValue === "bigint") {
|
|
21
|
+
return currentValue.toString();
|
|
22
|
+
}
|
|
23
|
+
if (typeof currentValue === "object" && currentValue !== null) {
|
|
24
|
+
if (seen.has(currentValue)) {
|
|
25
|
+
return "[Circular]";
|
|
26
|
+
}
|
|
27
|
+
seen.add(currentValue);
|
|
28
|
+
}
|
|
29
|
+
return currentValue;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface LoggerOptions {
|
|
34
|
+
extensionId: string;
|
|
35
|
+
debugLogPath: string;
|
|
36
|
+
isDebugEnabled: () => boolean;
|
|
37
|
+
ensureDebugDirectory: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ExtensionLogger {
|
|
41
|
+
debug: (event: string, details?: Record<string, unknown>) => void;
|
|
42
|
+
error: (error: unknown) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createExtensionLogger(options: LoggerOptions): ExtensionLogger {
|
|
46
|
+
const { extensionId, debugLogPath, isDebugEnabled, ensureDebugDirectory } = options;
|
|
47
|
+
|
|
48
|
+
const debug = (event: string, details: Record<string, unknown> = {}): void => {
|
|
49
|
+
if (!isDebugEnabled()) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
ensureDebugDirectory();
|
|
55
|
+
const line = safeJsonStringify({
|
|
56
|
+
timestamp: new Date().toISOString(),
|
|
57
|
+
extension: extensionId,
|
|
58
|
+
event,
|
|
59
|
+
...details,
|
|
60
|
+
});
|
|
61
|
+
appendFileSync(debugLogPath, `${line}\n`, "utf-8");
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error(`[${extensionId}] Failed to write debug log: ${getErrorMessage(error)}`);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const error = (cause: unknown): void => {
|
|
68
|
+
debug("runtime.error", { error: cause });
|
|
69
|
+
console.error(`[${extensionId}] ${getErrorMessage(cause)}`);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return { debug, error };
|
|
73
|
+
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clampInt,
|
|
3
|
+
DEFAULT_CONFIG,
|
|
4
|
+
isWindows,
|
|
5
|
+
resolveSoundFile,
|
|
6
|
+
SOUND_LOOPS,
|
|
7
|
+
} from "./config-store.js";
|
|
8
|
+
import type { NotificationType, VoiceNotifyConfig } from "./types.js";
|
|
9
|
+
|
|
10
|
+
interface ExecResult {
|
|
11
|
+
code: number;
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ExecRunner {
|
|
17
|
+
exec: (executable: string, args: string[], options?: { timeout?: number }) => Promise<ExecResult>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface AudioServiceOptions {
|
|
21
|
+
execRunner: ExecRunner;
|
|
22
|
+
getConfig: () => VoiceNotifyConfig;
|
|
23
|
+
debug: (event: string, details?: Record<string, unknown>) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class AudioNotificationService {
|
|
27
|
+
private readonly execRunner: ExecRunner;
|
|
28
|
+
private readonly getConfig: () => VoiceNotifyConfig;
|
|
29
|
+
private readonly debug: (event: string, details?: Record<string, unknown>) => void;
|
|
30
|
+
private voicesCache: { values: string[]; timestamp: number } = { values: [], timestamp: 0 };
|
|
31
|
+
|
|
32
|
+
public constructor(options: AudioServiceOptions) {
|
|
33
|
+
this.execRunner = options.execRunner;
|
|
34
|
+
this.getConfig = options.getConfig;
|
|
35
|
+
this.debug = options.debug;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public async wakeMonitor(): Promise<void> {
|
|
39
|
+
const config = this.getConfig();
|
|
40
|
+
if (!config.wakeMonitor) {
|
|
41
|
+
this.debug("wake.monitor.skipped", { reason: "disabled" });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const threshold = clampInt(config.idleThresholdSeconds, DEFAULT_CONFIG.idleThresholdSeconds, 5, 600);
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const idleSeconds = await this.getSystemIdleSeconds();
|
|
49
|
+
if (idleSeconds < threshold) {
|
|
50
|
+
this.debug("wake.monitor.skipped", {
|
|
51
|
+
reason: "below_threshold",
|
|
52
|
+
idleSeconds,
|
|
53
|
+
threshold,
|
|
54
|
+
});
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.debug("wake.monitor.attempt", {
|
|
59
|
+
platform: process.platform,
|
|
60
|
+
idleSeconds,
|
|
61
|
+
threshold,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (isWindows()) {
|
|
65
|
+
const script = "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('{F15}')";
|
|
66
|
+
const result = await this.runPowerShell(script, 6_000, "wake-monitor-windows");
|
|
67
|
+
if (!result.ok) {
|
|
68
|
+
throw new Error(result.stderr || result.stdout || "Failed to wake monitor on Windows");
|
|
69
|
+
}
|
|
70
|
+
this.debug("wake.monitor.success", { platform: process.platform });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (process.platform === "darwin") {
|
|
75
|
+
const result = await this.runProcess("caffeinate", ["-u", "-t", "1"], 6_000, "wake-monitor-macos");
|
|
76
|
+
if (!result.ok) {
|
|
77
|
+
throw new Error(result.stderr || result.stdout || "Failed to wake monitor on macOS");
|
|
78
|
+
}
|
|
79
|
+
this.debug("wake.monitor.success", { platform: process.platform });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (process.platform === "linux") {
|
|
84
|
+
const script =
|
|
85
|
+
"xset dpms force on || gdbus call --session --dest org.gnome.SettingsDaemon.Power --object-path /org/gnome/SettingsDaemon/Power --method org.gnome.SettingsDaemon.Power.Screen.StepUp";
|
|
86
|
+
const result = await this.runShell(script, 6_000, "wake-monitor-linux");
|
|
87
|
+
if (!result.ok) {
|
|
88
|
+
throw new Error(result.stderr || result.stdout || "Failed to wake monitor on Linux");
|
|
89
|
+
}
|
|
90
|
+
this.debug("wake.monitor.success", { platform: process.platform });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this.debug("wake.monitor.skipped", {
|
|
95
|
+
reason: "unsupported_platform",
|
|
96
|
+
platform: process.platform,
|
|
97
|
+
});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
this.debug("wake.monitor.error", { error });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
public async playWindowsSound(type: NotificationType): Promise<void> {
|
|
104
|
+
const config = this.getConfig();
|
|
105
|
+
if (!isWindows() || !config.enableSound) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const soundFile = resolveSoundFile(config, type);
|
|
110
|
+
const loops = SOUND_LOOPS[type];
|
|
111
|
+
const soundFileBase64 = soundFile ? Buffer.from(soundFile, "utf8").toString("base64") : "";
|
|
112
|
+
|
|
113
|
+
const script = `
|
|
114
|
+
$ErrorActionPreference = 'Stop'
|
|
115
|
+
$loops = ${loops}
|
|
116
|
+
$path = ''
|
|
117
|
+
if ('${soundFileBase64}') {
|
|
118
|
+
$path = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${soundFileBase64}'))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
if ($path -and (Test-Path -LiteralPath $path)) {
|
|
123
|
+
Add-Type -AssemblyName PresentationCore
|
|
124
|
+
$player = New-Object System.Windows.Media.MediaPlayer
|
|
125
|
+
try {
|
|
126
|
+
$player.Volume = 1.0
|
|
127
|
+
for ($i = 0; $i -lt $loops; $i++) {
|
|
128
|
+
$player.Open([Uri]::new($path))
|
|
129
|
+
|
|
130
|
+
$openDeadline = (Get-Date).AddSeconds(3)
|
|
131
|
+
while (-not $player.NaturalDuration.HasTimeSpan -and (Get-Date) -lt $openDeadline) {
|
|
132
|
+
Start-Sleep -Milliseconds 50
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
$durationMs = if ($player.NaturalDuration.HasTimeSpan) {
|
|
136
|
+
[Math]::Max(200, [Math]::Ceiling($player.NaturalDuration.TimeSpan.TotalMilliseconds))
|
|
137
|
+
} else {
|
|
138
|
+
2500
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
$player.Position = [TimeSpan]::Zero
|
|
142
|
+
$player.Play()
|
|
143
|
+
Start-Sleep -Milliseconds $durationMs
|
|
144
|
+
$player.Stop()
|
|
145
|
+
Start-Sleep -Milliseconds 120
|
|
146
|
+
}
|
|
147
|
+
} finally {
|
|
148
|
+
$player.Close()
|
|
149
|
+
}
|
|
150
|
+
exit 0
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for ($i = 0; $i -lt $loops; $i++) {
|
|
154
|
+
try {
|
|
155
|
+
[Console]::Beep(1047, 180)
|
|
156
|
+
Start-Sleep -Milliseconds 80
|
|
157
|
+
[Console]::Beep(1319, 220)
|
|
158
|
+
} catch {
|
|
159
|
+
Start-Sleep -Milliseconds 300
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
exit 0
|
|
163
|
+
} catch {
|
|
164
|
+
[Console]::Error.WriteLine($_.Exception.Message)
|
|
165
|
+
exit 1
|
|
166
|
+
}
|
|
167
|
+
`;
|
|
168
|
+
|
|
169
|
+
const result = await this.runPowerShell(script, 30_000, "play-windows-sound");
|
|
170
|
+
if (!result.ok) {
|
|
171
|
+
throw new Error(result.stderr || result.stdout || "Failed to play Windows sound");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
public async speakWithSapi(text: string): Promise<void> {
|
|
176
|
+
const config = this.getConfig();
|
|
177
|
+
if (!isWindows() || !config.enableTts) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const textBase64 = Buffer.from(text, "utf8").toString("base64");
|
|
182
|
+
const voiceBase64 = Buffer.from(config.ttsVoice, "utf8").toString("base64");
|
|
183
|
+
const rate = clampInt(config.ttsRate, DEFAULT_CONFIG.ttsRate, -10, 10);
|
|
184
|
+
|
|
185
|
+
const script = `
|
|
186
|
+
Add-Type -AssemblyName System.Speech
|
|
187
|
+
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
|
188
|
+
try {
|
|
189
|
+
$text = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${textBase64}'))
|
|
190
|
+
$voice = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${voiceBase64}'))
|
|
191
|
+
$synth.Rate = ${rate}
|
|
192
|
+
if ($voice) {
|
|
193
|
+
try { $synth.SelectVoice($voice) } catch { }
|
|
194
|
+
}
|
|
195
|
+
$synth.Speak($text)
|
|
196
|
+
exit 0
|
|
197
|
+
} catch {
|
|
198
|
+
[Console]::Error.WriteLine($_.Exception.Message)
|
|
199
|
+
exit 1
|
|
200
|
+
} finally {
|
|
201
|
+
if ($synth) { $synth.Dispose() }
|
|
202
|
+
}
|
|
203
|
+
`;
|
|
204
|
+
|
|
205
|
+
const result = await this.runPowerShell(script, 30_000, "speak-sapi");
|
|
206
|
+
if (!result.ok) {
|
|
207
|
+
throw new Error(result.stderr || result.stdout || "Failed to speak with SAPI");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
public async getInstalledVoices(force = false): Promise<string[]> {
|
|
212
|
+
const config = this.getConfig();
|
|
213
|
+
if (!isWindows()) {
|
|
214
|
+
return [config.ttsVoice];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const cacheAge = Date.now() - this.voicesCache.timestamp;
|
|
218
|
+
if (!force && this.voicesCache.values.length > 0 && cacheAge < 5 * 60_000) {
|
|
219
|
+
return this.voicesCache.values;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const script = `
|
|
223
|
+
Add-Type -AssemblyName System.Speech
|
|
224
|
+
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
|
225
|
+
try {
|
|
226
|
+
$synth.GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }
|
|
227
|
+
} finally {
|
|
228
|
+
if ($synth) { $synth.Dispose() }
|
|
229
|
+
}
|
|
230
|
+
`;
|
|
231
|
+
|
|
232
|
+
const result = await this.runPowerShell(script, 20_000, "list-sapi-voices");
|
|
233
|
+
if (!result.ok) {
|
|
234
|
+
throw new Error(result.stderr || result.stdout || "Failed to list SAPI voices");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const values = result.stdout
|
|
238
|
+
.split(/\r?\n/)
|
|
239
|
+
.map((line) => line.trim())
|
|
240
|
+
.filter(Boolean);
|
|
241
|
+
const unique = Array.from(new Set(values));
|
|
242
|
+
if (unique.length === 0) {
|
|
243
|
+
unique.push(config.ttsVoice);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (!unique.includes(config.ttsVoice)) {
|
|
247
|
+
unique.push(config.ttsVoice);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
this.voicesCache = { values: unique, timestamp: Date.now() };
|
|
251
|
+
return unique;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private encodePowerShell(script: string): string {
|
|
255
|
+
return Buffer.from(script, "utf16le").toString("base64");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private async runPowerShell(
|
|
259
|
+
script: string,
|
|
260
|
+
timeout = 20_000,
|
|
261
|
+
action = "unknown",
|
|
262
|
+
): Promise<{ ok: boolean; stdout: string; stderr: string }> {
|
|
263
|
+
const startedAt = Date.now();
|
|
264
|
+
const encoded = this.encodePowerShell(script);
|
|
265
|
+
const result = await this.execRunner.exec(
|
|
266
|
+
"powershell.exe",
|
|
267
|
+
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded],
|
|
268
|
+
{ timeout },
|
|
269
|
+
);
|
|
270
|
+
const payload = {
|
|
271
|
+
ok: result.code === 0,
|
|
272
|
+
stdout: result.stdout,
|
|
273
|
+
stderr: result.stderr,
|
|
274
|
+
};
|
|
275
|
+
this.debug("powershell.exec", {
|
|
276
|
+
action,
|
|
277
|
+
ok: payload.ok,
|
|
278
|
+
exitCode: result.code,
|
|
279
|
+
durationMs: Date.now() - startedAt,
|
|
280
|
+
stdoutPreview: payload.stdout.slice(0, 300),
|
|
281
|
+
stderrPreview: payload.stderr.slice(0, 300),
|
|
282
|
+
});
|
|
283
|
+
return payload;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private async runProcess(
|
|
287
|
+
executable: string,
|
|
288
|
+
args: string[],
|
|
289
|
+
timeout = 20_000,
|
|
290
|
+
action = "process",
|
|
291
|
+
): Promise<{ ok: boolean; stdout: string; stderr: string }> {
|
|
292
|
+
const startedAt = Date.now();
|
|
293
|
+
const result = await this.execRunner.exec(executable, args, { timeout });
|
|
294
|
+
const payload = {
|
|
295
|
+
ok: result.code === 0,
|
|
296
|
+
stdout: result.stdout,
|
|
297
|
+
stderr: result.stderr,
|
|
298
|
+
};
|
|
299
|
+
this.debug("process.exec", {
|
|
300
|
+
action,
|
|
301
|
+
executable,
|
|
302
|
+
ok: payload.ok,
|
|
303
|
+
exitCode: result.code,
|
|
304
|
+
durationMs: Date.now() - startedAt,
|
|
305
|
+
stdoutPreview: payload.stdout.slice(0, 300),
|
|
306
|
+
stderrPreview: payload.stderr.slice(0, 300),
|
|
307
|
+
});
|
|
308
|
+
return payload;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private async runShell(
|
|
312
|
+
script: string,
|
|
313
|
+
timeout = 20_000,
|
|
314
|
+
action = "shell",
|
|
315
|
+
): Promise<{ ok: boolean; stdout: string; stderr: string }> {
|
|
316
|
+
return this.runProcess("sh", ["-lc", script], timeout, action);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private parseIdleSeconds(stdout: string): number | null {
|
|
320
|
+
const candidate = stdout
|
|
321
|
+
.split(/\r?\n/)
|
|
322
|
+
.map((line) => line.trim())
|
|
323
|
+
.find((line) => line.length > 0);
|
|
324
|
+
if (!candidate) {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
const value = Number.parseFloat(candidate);
|
|
328
|
+
if (Number.isNaN(value) || !Number.isFinite(value) || value < 0) {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
return value;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private async getSystemIdleSeconds(): Promise<number> {
|
|
335
|
+
const config = this.getConfig();
|
|
336
|
+
const fallbackSeconds = clampInt(config.idleThresholdSeconds, DEFAULT_CONFIG.idleThresholdSeconds, 5, 600);
|
|
337
|
+
|
|
338
|
+
if (isWindows()) {
|
|
339
|
+
const script = `
|
|
340
|
+
$ErrorActionPreference = 'Stop'
|
|
341
|
+
$signature = @'
|
|
342
|
+
using System;
|
|
343
|
+
using System.Runtime.InteropServices;
|
|
344
|
+
public struct LASTINPUTINFO {
|
|
345
|
+
public uint cbSize;
|
|
346
|
+
public uint dwTime;
|
|
347
|
+
}
|
|
348
|
+
public static class IdleTimeProbe {
|
|
349
|
+
[DllImport("user32.dll")]
|
|
350
|
+
public static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);
|
|
351
|
+
|
|
352
|
+
[DllImport("kernel32.dll")]
|
|
353
|
+
public static extern ulong GetTickCount64();
|
|
354
|
+
}
|
|
355
|
+
'@
|
|
356
|
+
Add-Type -TypeDefinition $signature -Language CSharp
|
|
357
|
+
|
|
358
|
+
$info = New-Object LASTINPUTINFO
|
|
359
|
+
$info.cbSize = [System.Runtime.InteropServices.Marshal]::SizeOf([type]LASTINPUTINFO)
|
|
360
|
+
if (-not [IdleTimeProbe]::GetLastInputInfo([ref]$info)) {
|
|
361
|
+
throw 'GetLastInputInfo failed.'
|
|
362
|
+
}
|
|
363
|
+
$idleMs = [IdleTimeProbe]::GetTickCount64() - [uint64]$info.dwTime
|
|
364
|
+
[Math]::Floor($idleMs / 1000)
|
|
365
|
+
`;
|
|
366
|
+
const result = await this.runPowerShell(script, 8_000, "idle-seconds-windows");
|
|
367
|
+
const parsed = result.ok ? this.parseIdleSeconds(result.stdout) : null;
|
|
368
|
+
if (parsed !== null) {
|
|
369
|
+
return parsed;
|
|
370
|
+
}
|
|
371
|
+
this.debug("wake.monitor.idle_fallback", {
|
|
372
|
+
platform: process.platform,
|
|
373
|
+
reason: result.ok ? "unparseable" : "exec_failed",
|
|
374
|
+
fallbackSeconds,
|
|
375
|
+
});
|
|
376
|
+
return fallbackSeconds;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (process.platform === "darwin") {
|
|
380
|
+
const result = await this.runShell(
|
|
381
|
+
"ioreg -c IOHIDSystem | awk '/HIDIdleTime/ { printf(\"%d\\n\", $NF/1000000000); exit }'",
|
|
382
|
+
8_000,
|
|
383
|
+
"idle-seconds-macos",
|
|
384
|
+
);
|
|
385
|
+
const parsed = result.ok ? this.parseIdleSeconds(result.stdout) : null;
|
|
386
|
+
if (parsed !== null) {
|
|
387
|
+
return parsed;
|
|
388
|
+
}
|
|
389
|
+
this.debug("wake.monitor.idle_fallback", {
|
|
390
|
+
platform: process.platform,
|
|
391
|
+
reason: result.ok ? "unparseable" : "exec_failed",
|
|
392
|
+
fallbackSeconds,
|
|
393
|
+
});
|
|
394
|
+
return fallbackSeconds;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (process.platform === "linux") {
|
|
398
|
+
const script = "if command -v xprintidle >/dev/null 2>&1; then xprintidle | awk '{ printf(\"%d\\n\", $1/1000) }'; else echo ''; fi";
|
|
399
|
+
const result = await this.runShell(script, 8_000, "idle-seconds-linux");
|
|
400
|
+
const parsed = result.ok ? this.parseIdleSeconds(result.stdout) : null;
|
|
401
|
+
if (parsed !== null) {
|
|
402
|
+
return parsed;
|
|
403
|
+
}
|
|
404
|
+
this.debug("wake.monitor.idle_fallback", {
|
|
405
|
+
platform: process.platform,
|
|
406
|
+
reason: result.ok ? "unparseable" : "exec_failed",
|
|
407
|
+
fallbackSeconds,
|
|
408
|
+
});
|
|
409
|
+
return fallbackSeconds;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return fallbackSeconds;
|
|
413
|
+
}
|
|
414
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type NotificationType = "idle" | "permission" | "question" | "error";
|
|
2
|
+
export type NotificationMode = "sound-first" | "tts-first" | "both" | "sound-only";
|
|
3
|
+
export type NotifyLevel = "info" | "warning" | "error";
|
|
4
|
+
|
|
5
|
+
export interface VoiceNotifyConfig {
|
|
6
|
+
version: 1;
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
windowsOptimized: boolean;
|
|
9
|
+
notificationMode: NotificationMode;
|
|
10
|
+
enableSound: boolean;
|
|
11
|
+
enableTts: boolean;
|
|
12
|
+
enableDesktopNotification: boolean;
|
|
13
|
+
desktopNotificationTimeout: number;
|
|
14
|
+
wakeMonitor: boolean;
|
|
15
|
+
idleThresholdSeconds: number;
|
|
16
|
+
enableIdleNotification: boolean;
|
|
17
|
+
enablePermissionNotification: boolean;
|
|
18
|
+
enableQuestionNotification: boolean;
|
|
19
|
+
enableErrorNotification: boolean;
|
|
20
|
+
reminderEnabled: boolean;
|
|
21
|
+
reminderDelaySeconds: number;
|
|
22
|
+
followUpEnabled: boolean;
|
|
23
|
+
maxFollowUps: number;
|
|
24
|
+
followUpBackoffMultiplier: number;
|
|
25
|
+
minNotificationIntervalMs: number;
|
|
26
|
+
suppressIdleAfterError: boolean;
|
|
27
|
+
ttsVoice: string;
|
|
28
|
+
ttsRate: number;
|
|
29
|
+
idleSoundFile: string;
|
|
30
|
+
permissionSoundFile: string;
|
|
31
|
+
questionSoundFile: string;
|
|
32
|
+
errorSoundFile: string;
|
|
33
|
+
debugLog: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ReminderState {
|
|
37
|
+
timeoutId: NodeJS.Timeout;
|
|
38
|
+
scheduledAt: number;
|
|
39
|
+
followUpCount: number;
|
|
40
|
+
delaySeconds: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MessageSet {
|
|
44
|
+
initial: string[];
|
|
45
|
+
reminder: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type SoundFileField =
|
|
49
|
+
| "idleSoundFile"
|
|
50
|
+
| "permissionSoundFile"
|
|
51
|
+
| "questionSoundFile"
|
|
52
|
+
| "errorSoundFile";
|