@pulso/companion 0.2.2 → 0.2.3
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/dist/index.js +1254 -158
- package/package.json +8 -2
package/dist/index.js
CHANGED
|
@@ -3,17 +3,29 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import WebSocket from "ws";
|
|
5
5
|
import { exec, execSync } from "child_process";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
readFileSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
statSync,
|
|
12
|
+
copyFileSync,
|
|
13
|
+
renameSync
|
|
14
|
+
} from "fs";
|
|
7
15
|
import { homedir } from "os";
|
|
8
16
|
import { join, resolve, basename, extname } from "path";
|
|
9
|
-
var API_URL = process.env.PULSO_API_URL ?? process.argv.find((_, i, a) => a[i - 1] === "--api") ?? "https://
|
|
17
|
+
var API_URL = process.env.PULSO_API_URL ?? process.argv.find((_, i, a) => a[i - 1] === "--api") ?? "https://api.runpulso.com";
|
|
10
18
|
var TOKEN = process.env.PULSO_TOKEN ?? process.argv.find((_, i, a) => a[i - 1] === "--token") ?? "";
|
|
11
19
|
if (!TOKEN) {
|
|
12
20
|
console.error("\u274C Missing token. Set PULSO_TOKEN or use --token <jwt>");
|
|
13
|
-
console.error(
|
|
21
|
+
console.error(
|
|
22
|
+
" Get your token: log in to Pulso \u2192 Settings \u2192 Companion Token"
|
|
23
|
+
);
|
|
14
24
|
process.exit(1);
|
|
15
25
|
}
|
|
16
26
|
var ACCESS_LEVEL = process.env.PULSO_ACCESS ?? process.argv.find((_, i, a) => a[i - 1] === "--access") ?? "sandboxed";
|
|
27
|
+
var WAKE_WORD_ENABLED = process.argv.includes("--wake-word");
|
|
28
|
+
var PICOVOICE_ACCESS_KEY = process.env.PICOVOICE_ACCESS_KEY ?? process.argv.find((_, i, a) => a[i - 1] === "--picovoice-key") ?? "";
|
|
17
29
|
var WS_URL = API_URL.replace("https://", "wss://").replace("http://", "ws://") + "/ws/browser?token=" + TOKEN;
|
|
18
30
|
var HOME = homedir();
|
|
19
31
|
var RECONNECT_DELAY = 5e3;
|
|
@@ -31,10 +43,14 @@ function safePath(relative) {
|
|
|
31
43
|
}
|
|
32
44
|
function runAppleScript(script) {
|
|
33
45
|
return new Promise((resolve2, reject) => {
|
|
34
|
-
exec(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
46
|
+
exec(
|
|
47
|
+
`osascript -e '${script.replace(/'/g, "'\\''")}'`,
|
|
48
|
+
{ timeout: 15e3 },
|
|
49
|
+
(err, stdout, stderr) => {
|
|
50
|
+
if (err) reject(new Error(stderr || err.message));
|
|
51
|
+
else resolve2(stdout.trim());
|
|
52
|
+
}
|
|
53
|
+
);
|
|
38
54
|
});
|
|
39
55
|
}
|
|
40
56
|
function runShell(cmd, timeout = 1e4) {
|
|
@@ -58,7 +74,9 @@ function runSwift(code, timeout = 1e4) {
|
|
|
58
74
|
var SETUP_DONE_FILE = join(HOME, ".pulso-companion-setup");
|
|
59
75
|
async function setupPermissions() {
|
|
60
76
|
const isFirstRun = !existsSync(SETUP_DONE_FILE);
|
|
61
|
-
console.log(
|
|
77
|
+
console.log(
|
|
78
|
+
isFirstRun ? "\u{1F510} First run \u2014 setting up permissions...\n" : "\u{1F510} Checking permissions...\n"
|
|
79
|
+
);
|
|
62
80
|
const status = {};
|
|
63
81
|
const browserDefaults = [
|
|
64
82
|
["Google Chrome", "com.google.Chrome", "AllowJavaScriptAppleEvents"],
|
|
@@ -68,15 +86,24 @@ async function setupPermissions() {
|
|
|
68
86
|
];
|
|
69
87
|
for (const [name, domain, key] of browserDefaults) {
|
|
70
88
|
try {
|
|
71
|
-
execSync(`defaults write ${domain} ${key} -bool true 2>/dev/null`, {
|
|
72
|
-
|
|
89
|
+
execSync(`defaults write ${domain} ${key} -bool true 2>/dev/null`, {
|
|
90
|
+
timeout: 3e3
|
|
91
|
+
});
|
|
92
|
+
if (name === "Google Chrome" || name === "Safari")
|
|
93
|
+
status[`${name} JS`] = "\u2705";
|
|
73
94
|
} catch {
|
|
74
|
-
if (name === "Google Chrome" || name === "Safari")
|
|
95
|
+
if (name === "Google Chrome" || name === "Safari")
|
|
96
|
+
status[`${name} JS`] = "\u26A0\uFE0F";
|
|
75
97
|
}
|
|
76
98
|
}
|
|
77
|
-
for (const [browserName, processName] of [
|
|
99
|
+
for (const [browserName, processName] of [
|
|
100
|
+
["Google Chrome", "Google Chrome"],
|
|
101
|
+
["Safari", "Safari"]
|
|
102
|
+
]) {
|
|
78
103
|
try {
|
|
79
|
-
const running = execSync(`pgrep -x "${processName}" 2>/dev/null`, {
|
|
104
|
+
const running = execSync(`pgrep -x "${processName}" 2>/dev/null`, {
|
|
105
|
+
timeout: 2e3
|
|
106
|
+
}).toString().trim();
|
|
80
107
|
if (!running) continue;
|
|
81
108
|
const enableJsScript = browserName === "Google Chrome" ? `tell application "System Events"
|
|
82
109
|
tell process "Google Chrome"
|
|
@@ -101,24 +128,38 @@ async function setupPermissions() {
|
|
|
101
128
|
end if
|
|
102
129
|
end tell
|
|
103
130
|
end tell` : `return "skip_safari"`;
|
|
104
|
-
const result = execSync(
|
|
131
|
+
const result = execSync(
|
|
132
|
+
`osascript -e '${enableJsScript.replace(/'/g, "'\\''")}' 2>/dev/null`,
|
|
133
|
+
{ timeout: 1e4 }
|
|
134
|
+
).toString().trim();
|
|
105
135
|
if (result === "enabled") {
|
|
106
|
-
console.log(
|
|
136
|
+
console.log(
|
|
137
|
+
` \u2705 ${browserName}: JavaScript from Apple Events enabled via menu`
|
|
138
|
+
);
|
|
107
139
|
}
|
|
108
140
|
} catch {
|
|
109
141
|
}
|
|
110
142
|
}
|
|
111
143
|
try {
|
|
112
|
-
execSync(
|
|
144
|
+
execSync(
|
|
145
|
+
`osascript -e 'tell application "System Events" to name of first process' 2>/dev/null`,
|
|
146
|
+
{ timeout: 5e3 }
|
|
147
|
+
);
|
|
113
148
|
status["Accessibility"] = "\u2705";
|
|
114
149
|
} catch (err) {
|
|
115
150
|
const msg = err.message || "";
|
|
116
151
|
if (msg.includes("not allowed") || msg.includes("assistive") || msg.includes("-1719")) {
|
|
117
152
|
status["Accessibility"] = "\u26A0\uFE0F";
|
|
118
153
|
if (isFirstRun) {
|
|
119
|
-
console.log(
|
|
120
|
-
|
|
121
|
-
|
|
154
|
+
console.log(
|
|
155
|
+
" \u26A0\uFE0F Accessibility: macOS should show a permission dialog."
|
|
156
|
+
);
|
|
157
|
+
console.log(
|
|
158
|
+
" If not, go to: System Settings \u2192 Privacy & Security \u2192 Accessibility"
|
|
159
|
+
);
|
|
160
|
+
console.log(
|
|
161
|
+
" Add your terminal app (Terminal, iTerm, Warp, etc.)\n"
|
|
162
|
+
);
|
|
122
163
|
}
|
|
123
164
|
} else {
|
|
124
165
|
status["Accessibility"] = "\u2705";
|
|
@@ -135,8 +176,12 @@ async function setupPermissions() {
|
|
|
135
176
|
} else {
|
|
136
177
|
status["Screen Recording"] = "\u26A0\uFE0F";
|
|
137
178
|
if (isFirstRun) {
|
|
138
|
-
console.log(
|
|
139
|
-
|
|
179
|
+
console.log(
|
|
180
|
+
" \u26A0\uFE0F Screen Recording: macOS should show a permission dialog."
|
|
181
|
+
);
|
|
182
|
+
console.log(
|
|
183
|
+
" If not, go to: System Settings \u2192 Privacy & Security \u2192 Screen Recording"
|
|
184
|
+
);
|
|
140
185
|
console.log(" Add your terminal app\n");
|
|
141
186
|
}
|
|
142
187
|
}
|
|
@@ -146,8 +191,12 @@ async function setupPermissions() {
|
|
|
146
191
|
} catch {
|
|
147
192
|
status["Screen Recording"] = "\u26A0\uFE0F";
|
|
148
193
|
if (isFirstRun) {
|
|
149
|
-
console.log(
|
|
150
|
-
|
|
194
|
+
console.log(
|
|
195
|
+
" \u26A0\uFE0F Screen Recording: permission needed for screenshots."
|
|
196
|
+
);
|
|
197
|
+
console.log(
|
|
198
|
+
" Go to: System Settings \u2192 Privacy & Security \u2192 Screen Recording\n"
|
|
199
|
+
);
|
|
151
200
|
}
|
|
152
201
|
}
|
|
153
202
|
console.log(" Permission Status:");
|
|
@@ -164,9 +213,14 @@ async function setupPermissions() {
|
|
|
164
213
|
}
|
|
165
214
|
const needsManual = status["Accessibility"] === "\u26A0\uFE0F" || status["Screen Recording"] === "\u26A0\uFE0F";
|
|
166
215
|
if (isFirstRun && needsManual) {
|
|
167
|
-
console.log(
|
|
216
|
+
console.log(
|
|
217
|
+
" Opening System Settings for you to approve permissions...\n"
|
|
218
|
+
);
|
|
168
219
|
try {
|
|
169
|
-
execSync(
|
|
220
|
+
execSync(
|
|
221
|
+
'open "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"',
|
|
222
|
+
{ timeout: 3e3 }
|
|
223
|
+
);
|
|
170
224
|
} catch {
|
|
171
225
|
}
|
|
172
226
|
}
|
|
@@ -194,6 +248,23 @@ async function spotifySearch(query) {
|
|
|
194
248
|
}
|
|
195
249
|
} catch {
|
|
196
250
|
}
|
|
251
|
+
const songKeywords = /\b(song|track|canção|cancion|musica|música|remix|feat|ft\.|version|cover|live|acoustic)\b/i;
|
|
252
|
+
const hasMultipleWords = key.split(/\s+/).length >= 3;
|
|
253
|
+
const looksLikeArtist = !songKeywords.test(key) && !hasMultipleWords;
|
|
254
|
+
if (looksLikeArtist) {
|
|
255
|
+
const artistId = await searchForSpotifyArtist(query);
|
|
256
|
+
if (artistId) {
|
|
257
|
+
const result2 = {
|
|
258
|
+
uri: `spotify:artist:${artistId}`,
|
|
259
|
+
name: "Top Songs",
|
|
260
|
+
artist: query
|
|
261
|
+
};
|
|
262
|
+
searchCache.set(key, { ...result2, ts: Date.now() });
|
|
263
|
+
pushToServerCache(query, result2).catch(() => {
|
|
264
|
+
});
|
|
265
|
+
return result2;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
197
268
|
let trackIds = await searchBraveForSpotifyTracks(query);
|
|
198
269
|
if (trackIds.length === 0) {
|
|
199
270
|
trackIds = await searchDDGForSpotifyTracks(query);
|
|
@@ -213,6 +284,72 @@ async function spotifySearch(query) {
|
|
|
213
284
|
});
|
|
214
285
|
return result;
|
|
215
286
|
}
|
|
287
|
+
async function searchForSpotifyArtist(query) {
|
|
288
|
+
const engines = [
|
|
289
|
+
{
|
|
290
|
+
name: "brave",
|
|
291
|
+
fn: () => searchWebForSpotifyIds(
|
|
292
|
+
query,
|
|
293
|
+
"artist",
|
|
294
|
+
`https://search.brave.com/search?q=${encodeURIComponent(`${query} artist site:open.spotify.com`)}&source=web`
|
|
295
|
+
)
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
name: "ddg",
|
|
299
|
+
fn: () => searchWebForSpotifyIds(
|
|
300
|
+
query,
|
|
301
|
+
"artist",
|
|
302
|
+
`https://html.duckduckgo.com/html/?q=${encodeURIComponent(`${query} artist site:open.spotify.com`)}`
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
];
|
|
306
|
+
for (const engine of engines) {
|
|
307
|
+
try {
|
|
308
|
+
const ids = await engine.fn();
|
|
309
|
+
if (ids.length > 0) return ids[0];
|
|
310
|
+
} catch {
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
async function searchWebForSpotifyIds(query, type, url) {
|
|
316
|
+
try {
|
|
317
|
+
const controller = new AbortController();
|
|
318
|
+
const timeout = setTimeout(() => controller.abort(), 8e3);
|
|
319
|
+
const opts = {
|
|
320
|
+
headers: { "User-Agent": UA, Accept: "text/html" },
|
|
321
|
+
signal: controller.signal
|
|
322
|
+
};
|
|
323
|
+
if (url.includes("duckduckgo.com/html")) {
|
|
324
|
+
const searchUrl = new URL(url);
|
|
325
|
+
const q = searchUrl.searchParams.get("q") ?? query;
|
|
326
|
+
Object.assign(opts, {
|
|
327
|
+
method: "POST",
|
|
328
|
+
headers: {
|
|
329
|
+
...opts.headers,
|
|
330
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
331
|
+
},
|
|
332
|
+
body: `q=${encodeURIComponent(q)}`
|
|
333
|
+
});
|
|
334
|
+
url = "https://html.duckduckgo.com/html/";
|
|
335
|
+
}
|
|
336
|
+
const res = await fetch(url, opts);
|
|
337
|
+
clearTimeout(timeout);
|
|
338
|
+
if (!res.ok) return [];
|
|
339
|
+
const html = await res.text();
|
|
340
|
+
const ids = /* @__PURE__ */ new Set();
|
|
341
|
+
const pattern = new RegExp(
|
|
342
|
+
`open\\.spotify\\.com(?:/intl-[a-z]+)?/${type}/([a-zA-Z0-9]{22})`,
|
|
343
|
+
"g"
|
|
344
|
+
);
|
|
345
|
+
for (const m of html.matchAll(pattern)) {
|
|
346
|
+
ids.add(m[1]);
|
|
347
|
+
}
|
|
348
|
+
return [...ids];
|
|
349
|
+
} catch {
|
|
350
|
+
return [];
|
|
351
|
+
}
|
|
352
|
+
}
|
|
216
353
|
async function searchBraveForSpotifyTracks(query) {
|
|
217
354
|
try {
|
|
218
355
|
const searchQuery = `spotify track ${query} site:open.spotify.com`;
|
|
@@ -227,7 +364,9 @@ async function searchBraveForSpotifyTracks(query) {
|
|
|
227
364
|
if (!res.ok) return [];
|
|
228
365
|
const html = await res.text();
|
|
229
366
|
const trackIds = /* @__PURE__ */ new Set();
|
|
230
|
-
for (const m of html.matchAll(
|
|
367
|
+
for (const m of html.matchAll(
|
|
368
|
+
/https?:\/\/open\.spotify\.com\/track\/([a-zA-Z0-9]{22})/g
|
|
369
|
+
)) {
|
|
231
370
|
trackIds.add(m[1]);
|
|
232
371
|
}
|
|
233
372
|
return [...trackIds];
|
|
@@ -249,7 +388,9 @@ async function searchDDGForSpotifyTracks(query) {
|
|
|
249
388
|
if (!res.ok) return [];
|
|
250
389
|
const html = await res.text();
|
|
251
390
|
const trackIds = /* @__PURE__ */ new Set();
|
|
252
|
-
for (const m of html.matchAll(
|
|
391
|
+
for (const m of html.matchAll(
|
|
392
|
+
/open\.spotify\.com(?:\/intl-[a-z]+)?\/track\/([a-zA-Z0-9]{22})/g
|
|
393
|
+
)) {
|
|
253
394
|
trackIds.add(m[1]);
|
|
254
395
|
}
|
|
255
396
|
return [...trackIds];
|
|
@@ -275,7 +416,9 @@ async function searchStartpageForSpotifyTracks(query) {
|
|
|
275
416
|
if (!res.ok) return [];
|
|
276
417
|
const html = await res.text();
|
|
277
418
|
const trackIds = /* @__PURE__ */ new Set();
|
|
278
|
-
for (const m of html.matchAll(
|
|
419
|
+
for (const m of html.matchAll(
|
|
420
|
+
/open\.spotify\.com(?:\/intl-[a-z]+)?\/track\/([a-zA-Z0-9]{22})/g
|
|
421
|
+
)) {
|
|
279
422
|
trackIds.add(m[1]);
|
|
280
423
|
}
|
|
281
424
|
return [...trackIds];
|
|
@@ -294,7 +437,9 @@ async function getTrackMetadata(trackId) {
|
|
|
294
437
|
clearTimeout(timeout);
|
|
295
438
|
if (!res.ok) return null;
|
|
296
439
|
const html = await res.text();
|
|
297
|
-
const scriptMatch = html.match(
|
|
440
|
+
const scriptMatch = html.match(
|
|
441
|
+
/<script[^>]*>(\{"props":\{"pageProps".*?\})<\/script>/s
|
|
442
|
+
);
|
|
298
443
|
if (!scriptMatch) return null;
|
|
299
444
|
const data = JSON.parse(scriptMatch[1]);
|
|
300
445
|
const entity = data?.props?.pageProps?.state?.data?.entity;
|
|
@@ -346,7 +491,8 @@ async function handleCommand(command, params) {
|
|
|
346
491
|
case "sys_notification": {
|
|
347
492
|
const title = params.title;
|
|
348
493
|
const message = params.message;
|
|
349
|
-
if (!title || !message)
|
|
494
|
+
if (!title || !message)
|
|
495
|
+
return { success: false, error: "Missing title or message" };
|
|
350
496
|
await runAppleScript(
|
|
351
497
|
`display notification "${message.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"`
|
|
352
498
|
);
|
|
@@ -375,26 +521,48 @@ async function handleCommand(command, params) {
|
|
|
375
521
|
await runAppleScript('tell application "Spotify" to next track');
|
|
376
522
|
return { success: true, data: { action: "next" } };
|
|
377
523
|
case "previous":
|
|
378
|
-
await runAppleScript(
|
|
524
|
+
await runAppleScript(
|
|
525
|
+
'tell application "Spotify" to previous track'
|
|
526
|
+
);
|
|
379
527
|
return { success: true, data: { action: "previous" } };
|
|
380
528
|
case "now_playing": {
|
|
381
|
-
const name = await runAppleScript(
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const
|
|
385
|
-
|
|
529
|
+
const name = await runAppleScript(
|
|
530
|
+
'tell application "Spotify" to name of current track'
|
|
531
|
+
);
|
|
532
|
+
const artist = await runAppleScript(
|
|
533
|
+
'tell application "Spotify" to artist of current track'
|
|
534
|
+
);
|
|
535
|
+
const album = await runAppleScript(
|
|
536
|
+
'tell application "Spotify" to album of current track'
|
|
537
|
+
);
|
|
538
|
+
const state = await runAppleScript(
|
|
539
|
+
'tell application "Spotify" to player state as string'
|
|
540
|
+
);
|
|
541
|
+
return {
|
|
542
|
+
success: true,
|
|
543
|
+
data: { track: name, artist, album, state }
|
|
544
|
+
};
|
|
386
545
|
}
|
|
387
546
|
case "search_play": {
|
|
388
547
|
const query = params.query;
|
|
389
|
-
if (!query)
|
|
548
|
+
if (!query)
|
|
549
|
+
return { success: false, error: "Missing search query" };
|
|
390
550
|
const result = await spotifySearch(query);
|
|
391
551
|
if (result) {
|
|
392
|
-
await runAppleScript(
|
|
552
|
+
await runAppleScript(
|
|
553
|
+
`tell application "Spotify" to play track "${result.uri}"`
|
|
554
|
+
);
|
|
393
555
|
await new Promise((r) => setTimeout(r, 1500));
|
|
394
556
|
try {
|
|
395
|
-
const track = await runAppleScript(
|
|
396
|
-
|
|
397
|
-
|
|
557
|
+
const track = await runAppleScript(
|
|
558
|
+
'tell application "Spotify" to name of current track'
|
|
559
|
+
);
|
|
560
|
+
const artist = await runAppleScript(
|
|
561
|
+
'tell application "Spotify" to artist of current track'
|
|
562
|
+
);
|
|
563
|
+
const state = await runAppleScript(
|
|
564
|
+
'tell application "Spotify" to player state as string'
|
|
565
|
+
);
|
|
398
566
|
return {
|
|
399
567
|
success: true,
|
|
400
568
|
data: {
|
|
@@ -405,10 +573,19 @@ async function handleCommand(command, params) {
|
|
|
405
573
|
}
|
|
406
574
|
};
|
|
407
575
|
} catch {
|
|
408
|
-
return {
|
|
576
|
+
return {
|
|
577
|
+
success: true,
|
|
578
|
+
data: {
|
|
579
|
+
searched: query,
|
|
580
|
+
resolved: `${result.name} - ${result.artist}`,
|
|
581
|
+
note: "Playing track"
|
|
582
|
+
}
|
|
583
|
+
};
|
|
409
584
|
}
|
|
410
585
|
}
|
|
411
|
-
await runShell(
|
|
586
|
+
await runShell(
|
|
587
|
+
`open "spotify:search:${encodeURIComponent(query)}"`
|
|
588
|
+
);
|
|
412
589
|
return {
|
|
413
590
|
success: true,
|
|
414
591
|
data: {
|
|
@@ -420,34 +597,61 @@ async function handleCommand(command, params) {
|
|
|
420
597
|
}
|
|
421
598
|
case "volume": {
|
|
422
599
|
const level = params.level;
|
|
423
|
-
if (level === void 0 || level < 0 || level > 100)
|
|
424
|
-
|
|
600
|
+
if (level === void 0 || level < 0 || level > 100)
|
|
601
|
+
return { success: false, error: "Volume must be 0-100" };
|
|
602
|
+
await runAppleScript(
|
|
603
|
+
`tell application "Spotify" to set sound volume to ${level}`
|
|
604
|
+
);
|
|
425
605
|
return { success: true, data: { volume: level } };
|
|
426
606
|
}
|
|
427
607
|
case "shuffle": {
|
|
428
608
|
const enabled = params.enabled;
|
|
429
|
-
await runAppleScript(
|
|
609
|
+
await runAppleScript(
|
|
610
|
+
`tell application "Spotify" to set shuffling to ${enabled ? "true" : "false"}`
|
|
611
|
+
);
|
|
430
612
|
return { success: true, data: { shuffling: enabled } };
|
|
431
613
|
}
|
|
432
614
|
case "repeat": {
|
|
433
615
|
const mode = params.mode;
|
|
434
|
-
if (!mode)
|
|
435
|
-
|
|
616
|
+
if (!mode)
|
|
617
|
+
return {
|
|
618
|
+
success: false,
|
|
619
|
+
error: "Missing mode (off, context, track)"
|
|
620
|
+
};
|
|
621
|
+
const repeatMap = {
|
|
622
|
+
off: "off",
|
|
623
|
+
context: "context",
|
|
624
|
+
track: "track"
|
|
625
|
+
};
|
|
436
626
|
const val = repeatMap[mode];
|
|
437
|
-
if (!val)
|
|
438
|
-
|
|
627
|
+
if (!val)
|
|
628
|
+
return {
|
|
629
|
+
success: false,
|
|
630
|
+
error: "Mode must be: off, context, or track"
|
|
631
|
+
};
|
|
632
|
+
await runAppleScript(
|
|
633
|
+
`tell application "Spotify" to set repeating to ${val !== "off"}`
|
|
634
|
+
);
|
|
439
635
|
return { success: true, data: { repeating: mode } };
|
|
440
636
|
}
|
|
441
637
|
default:
|
|
442
|
-
return {
|
|
638
|
+
return {
|
|
639
|
+
success: false,
|
|
640
|
+
error: `Unknown Spotify action: ${action}`
|
|
641
|
+
};
|
|
443
642
|
}
|
|
444
643
|
}
|
|
445
644
|
case "sys_file_read": {
|
|
446
645
|
const path = params.path;
|
|
447
646
|
if (!path) return { success: false, error: "Missing file path" };
|
|
448
647
|
const fullPath = safePath(path);
|
|
449
|
-
if (!fullPath)
|
|
450
|
-
|
|
648
|
+
if (!fullPath)
|
|
649
|
+
return {
|
|
650
|
+
success: false,
|
|
651
|
+
error: `Access denied. Only files in ${SAFE_DIRS.join(", ")} are allowed.`
|
|
652
|
+
};
|
|
653
|
+
if (!existsSync(fullPath))
|
|
654
|
+
return { success: false, error: `File not found: ${path}` };
|
|
451
655
|
const content = readFileSync(fullPath, "utf-8");
|
|
452
656
|
return {
|
|
453
657
|
success: true,
|
|
@@ -462,9 +666,14 @@ async function handleCommand(command, params) {
|
|
|
462
666
|
case "sys_file_write": {
|
|
463
667
|
const path = params.path;
|
|
464
668
|
const content = params.content;
|
|
465
|
-
if (!path || !content)
|
|
669
|
+
if (!path || !content)
|
|
670
|
+
return { success: false, error: "Missing path or content" };
|
|
466
671
|
const fullPath = safePath(path);
|
|
467
|
-
if (!fullPath)
|
|
672
|
+
if (!fullPath)
|
|
673
|
+
return {
|
|
674
|
+
success: false,
|
|
675
|
+
error: `Access denied. Only files in ${SAFE_DIRS.join(", ")} are allowed.`
|
|
676
|
+
};
|
|
468
677
|
writeFileSync(fullPath, content, "utf-8");
|
|
469
678
|
return { success: true, data: { path, written: content.length } };
|
|
470
679
|
}
|
|
@@ -473,20 +682,33 @@ async function handleCommand(command, params) {
|
|
|
473
682
|
const pngPath = `/tmp/pulso-ss-${ts}.png`;
|
|
474
683
|
const jpgPath = `/tmp/pulso-ss-${ts}.jpg`;
|
|
475
684
|
await runShell(`screencapture -x ${pngPath}`, 15e3);
|
|
476
|
-
if (!existsSync(pngPath))
|
|
685
|
+
if (!existsSync(pngPath))
|
|
686
|
+
return { success: false, error: "Screenshot failed" };
|
|
477
687
|
try {
|
|
478
|
-
await runShell(
|
|
688
|
+
await runShell(
|
|
689
|
+
`sips --resampleWidth 1280 --setProperty format jpeg --setProperty formatOptions 60 ${pngPath} --out ${jpgPath}`,
|
|
690
|
+
1e4
|
|
691
|
+
);
|
|
479
692
|
} catch {
|
|
480
693
|
const buf2 = readFileSync(pngPath);
|
|
481
694
|
exec(`rm -f ${pngPath}`);
|
|
482
|
-
return {
|
|
695
|
+
return {
|
|
696
|
+
success: true,
|
|
697
|
+
data: {
|
|
698
|
+
image: `data:image/png;base64,${buf2.toString("base64")}`,
|
|
699
|
+
format: "png",
|
|
700
|
+
note: "Full screen screenshot"
|
|
701
|
+
}
|
|
702
|
+
};
|
|
483
703
|
}
|
|
484
704
|
const buf = readFileSync(jpgPath);
|
|
485
705
|
const base64 = buf.toString("base64");
|
|
486
706
|
exec(`rm -f ${pngPath} ${jpgPath}`);
|
|
487
707
|
let screenSize = "unknown";
|
|
488
708
|
try {
|
|
489
|
-
screenSize = await runShell(
|
|
709
|
+
screenSize = await runShell(
|
|
710
|
+
`system_profiler SPDisplaysDataType 2>/dev/null | grep Resolution | head -1 | sed 's/.*: //'`
|
|
711
|
+
);
|
|
490
712
|
} catch {
|
|
491
713
|
}
|
|
492
714
|
return {
|
|
@@ -505,7 +727,8 @@ async function handleCommand(command, params) {
|
|
|
505
727
|
const x = Number(params.x);
|
|
506
728
|
const y = Number(params.y);
|
|
507
729
|
const button = params.button || "left";
|
|
508
|
-
if (isNaN(x) || isNaN(y))
|
|
730
|
+
if (isNaN(x) || isNaN(y))
|
|
731
|
+
return { success: false, error: "Missing x, y coordinates" };
|
|
509
732
|
const mouseType = button === "right" ? "rightMouseDown" : "leftMouseDown";
|
|
510
733
|
const mouseTypeUp = button === "right" ? "rightMouseUp" : "leftMouseUp";
|
|
511
734
|
const mouseButton = button === "right" ? ".right" : ".left";
|
|
@@ -524,7 +747,8 @@ print("clicked")`;
|
|
|
524
747
|
case "sys_mouse_double_click": {
|
|
525
748
|
const x = Number(params.x);
|
|
526
749
|
const y = Number(params.y);
|
|
527
|
-
if (isNaN(x) || isNaN(y))
|
|
750
|
+
if (isNaN(x) || isNaN(y))
|
|
751
|
+
return { success: false, error: "Missing x, y coordinates" };
|
|
528
752
|
const swift = `
|
|
529
753
|
import Cocoa
|
|
530
754
|
let p = CGPoint(x: ${x}, y: ${y})
|
|
@@ -552,7 +776,8 @@ print("double-clicked")`;
|
|
|
552
776
|
const y = Number(params.y) || 0;
|
|
553
777
|
const scrollY = Number(params.scrollY) || 0;
|
|
554
778
|
const scrollX = Number(params.scrollX) || 0;
|
|
555
|
-
if (!scrollY && !scrollX)
|
|
779
|
+
if (!scrollY && !scrollX)
|
|
780
|
+
return { success: false, error: "Missing scrollY or scrollX" };
|
|
556
781
|
const swift = `
|
|
557
782
|
import Cocoa
|
|
558
783
|
let p = CGPoint(x: ${x}, y: ${y})
|
|
@@ -563,12 +788,17 @@ let scroll = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2,
|
|
|
563
788
|
scroll.post(tap: .cghidEventTap)
|
|
564
789
|
print("scrolled")`;
|
|
565
790
|
await runSwift(swift);
|
|
566
|
-
return {
|
|
791
|
+
return {
|
|
792
|
+
success: true,
|
|
793
|
+
data: { scrolled: { x, y, scrollY, scrollX } }
|
|
794
|
+
};
|
|
567
795
|
}
|
|
568
796
|
case "sys_keyboard_type": {
|
|
569
797
|
const text = params.text;
|
|
570
798
|
if (!text) return { success: false, error: "Missing text" };
|
|
571
|
-
await runAppleScript(
|
|
799
|
+
await runAppleScript(
|
|
800
|
+
`tell application "System Events" to keystroke "${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
|
|
801
|
+
);
|
|
572
802
|
return { success: true, data: { typed: text.slice(0, 100) } };
|
|
573
803
|
}
|
|
574
804
|
case "sys_key_press": {
|
|
@@ -619,19 +849,27 @@ print("scrolled")`;
|
|
|
619
849
|
const keyCode = keyCodeMap[key.toLowerCase()];
|
|
620
850
|
if (keyCode !== void 0) {
|
|
621
851
|
const using = modStr.length > 0 ? ` using {${modStr.join(", ")}}` : "";
|
|
622
|
-
await runAppleScript(
|
|
852
|
+
await runAppleScript(
|
|
853
|
+
`tell application "System Events" to key code ${keyCode}${using}`
|
|
854
|
+
);
|
|
623
855
|
} else if (key.length === 1) {
|
|
624
856
|
const using = modStr.length > 0 ? ` using {${modStr.join(", ")}}` : "";
|
|
625
|
-
await runAppleScript(
|
|
857
|
+
await runAppleScript(
|
|
858
|
+
`tell application "System Events" to keystroke "${key}"${using}`
|
|
859
|
+
);
|
|
626
860
|
} else {
|
|
627
|
-
return {
|
|
861
|
+
return {
|
|
862
|
+
success: false,
|
|
863
|
+
error: `Unknown key: ${key}. Use single characters or: enter, tab, escape, delete, space, up, down, left, right, f1-f12, home, end, pageup, pagedown`
|
|
864
|
+
};
|
|
628
865
|
}
|
|
629
866
|
return { success: true, data: { pressed: key, modifiers } };
|
|
630
867
|
}
|
|
631
868
|
case "sys_mouse_move": {
|
|
632
869
|
const x = Number(params.x);
|
|
633
870
|
const y = Number(params.y);
|
|
634
|
-
if (isNaN(x) || isNaN(y))
|
|
871
|
+
if (isNaN(x) || isNaN(y))
|
|
872
|
+
return { success: false, error: "Missing x, y coordinates" };
|
|
635
873
|
const swift = `
|
|
636
874
|
import Cocoa
|
|
637
875
|
let p = CGPoint(x: ${x}, y: ${y})
|
|
@@ -646,7 +884,8 @@ print("moved")`;
|
|
|
646
884
|
const fromY = Number(params.fromY);
|
|
647
885
|
const toX = Number(params.toX);
|
|
648
886
|
const toY = Number(params.toY);
|
|
649
|
-
if ([fromX, fromY, toX, toY].some(isNaN))
|
|
887
|
+
if ([fromX, fromY, toX, toY].some(isNaN))
|
|
888
|
+
return { success: false, error: "Missing fromX, fromY, toX, toY" };
|
|
650
889
|
const swift = `
|
|
651
890
|
import Cocoa
|
|
652
891
|
let from = CGPoint(x: ${fromX}, y: ${fromY})
|
|
@@ -666,7 +905,12 @@ let u = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosit
|
|
|
666
905
|
u.post(tap: .cghidEventTap)
|
|
667
906
|
print("dragged")`;
|
|
668
907
|
await runSwift(swift);
|
|
669
|
-
return {
|
|
908
|
+
return {
|
|
909
|
+
success: true,
|
|
910
|
+
data: {
|
|
911
|
+
dragged: { from: { x: fromX, y: fromY }, to: { x: toX, y: toY } }
|
|
912
|
+
}
|
|
913
|
+
};
|
|
670
914
|
}
|
|
671
915
|
case "sys_get_cursor_position": {
|
|
672
916
|
const swift = `
|
|
@@ -684,7 +928,13 @@ print("\\(x),\\(y)")`;
|
|
|
684
928
|
}
|
|
685
929
|
// ── Browser Automation ─────────────────────────────────
|
|
686
930
|
case "sys_browser_list_tabs": {
|
|
687
|
-
const browsers = [
|
|
931
|
+
const browsers = [
|
|
932
|
+
"Google Chrome",
|
|
933
|
+
"Safari",
|
|
934
|
+
"Arc",
|
|
935
|
+
"Firefox",
|
|
936
|
+
"Microsoft Edge"
|
|
937
|
+
];
|
|
688
938
|
const allTabs = [];
|
|
689
939
|
for (const browser of browsers) {
|
|
690
940
|
try {
|
|
@@ -708,7 +958,12 @@ print("\\(x),\\(y)")`;
|
|
|
708
958
|
const tabStr = rest.join("~~~");
|
|
709
959
|
const pairs = tabStr.split("|||").filter(Boolean);
|
|
710
960
|
for (let i = 0; i < pairs.length - 1; i += 2) {
|
|
711
|
-
allTabs.push({
|
|
961
|
+
allTabs.push({
|
|
962
|
+
browser: "Safari",
|
|
963
|
+
title: pairs[i],
|
|
964
|
+
url: pairs[i + 1],
|
|
965
|
+
active: pairs[i + 1] === activeURL.trim()
|
|
966
|
+
});
|
|
712
967
|
}
|
|
713
968
|
} else {
|
|
714
969
|
const tabData = await runAppleScript(`
|
|
@@ -726,13 +981,21 @@ print("\\(x),\\(y)")`;
|
|
|
726
981
|
const tabStr = rest.join("~~~");
|
|
727
982
|
const pairs = tabStr.split("|||").filter(Boolean);
|
|
728
983
|
for (let i = 0; i < pairs.length - 1; i += 2) {
|
|
729
|
-
allTabs.push({
|
|
984
|
+
allTabs.push({
|
|
985
|
+
browser,
|
|
986
|
+
title: pairs[i],
|
|
987
|
+
url: pairs[i + 1],
|
|
988
|
+
active: pairs[i + 1] === activeURL.trim()
|
|
989
|
+
});
|
|
730
990
|
}
|
|
731
991
|
}
|
|
732
992
|
} catch {
|
|
733
993
|
}
|
|
734
994
|
}
|
|
735
|
-
return {
|
|
995
|
+
return {
|
|
996
|
+
success: true,
|
|
997
|
+
data: { tabs: allTabs, count: allTabs.length }
|
|
998
|
+
};
|
|
736
999
|
}
|
|
737
1000
|
case "sys_browser_navigate": {
|
|
738
1001
|
const url = params.url;
|
|
@@ -760,7 +1023,10 @@ print("\\(x),\\(y)")`;
|
|
|
760
1023
|
}
|
|
761
1024
|
return { success: true, data: { navigated: url, browser } };
|
|
762
1025
|
} catch (err) {
|
|
763
|
-
return {
|
|
1026
|
+
return {
|
|
1027
|
+
success: false,
|
|
1028
|
+
error: `Failed to navigate: ${err.message}`
|
|
1029
|
+
};
|
|
764
1030
|
}
|
|
765
1031
|
}
|
|
766
1032
|
case "sys_browser_new_tab": {
|
|
@@ -783,7 +1049,10 @@ print("\\(x),\\(y)")`;
|
|
|
783
1049
|
}
|
|
784
1050
|
return { success: true, data: { opened: url, browser } };
|
|
785
1051
|
} catch (err) {
|
|
786
|
-
return {
|
|
1052
|
+
return {
|
|
1053
|
+
success: false,
|
|
1054
|
+
error: `Failed to open tab: ${err.message}`
|
|
1055
|
+
};
|
|
787
1056
|
}
|
|
788
1057
|
}
|
|
789
1058
|
case "sys_browser_read_page": {
|
|
@@ -809,32 +1078,53 @@ print("\\(x),\\(y)")`;
|
|
|
809
1078
|
}
|
|
810
1079
|
} catch {
|
|
811
1080
|
try {
|
|
812
|
-
const savedClipboard = await runShell(
|
|
813
|
-
|
|
1081
|
+
const savedClipboard = await runShell(
|
|
1082
|
+
"pbpaste 2>/dev/null || true"
|
|
1083
|
+
);
|
|
1084
|
+
await runAppleScript(
|
|
1085
|
+
`tell application "${browser.replace(/"/g, '\\"')}" to activate`
|
|
1086
|
+
);
|
|
814
1087
|
await new Promise((r) => setTimeout(r, 300));
|
|
815
|
-
await runAppleScript(
|
|
1088
|
+
await runAppleScript(
|
|
1089
|
+
'tell application "System Events" to keystroke "a" using command down'
|
|
1090
|
+
);
|
|
816
1091
|
await new Promise((r) => setTimeout(r, 200));
|
|
817
|
-
await runAppleScript(
|
|
1092
|
+
await runAppleScript(
|
|
1093
|
+
'tell application "System Events" to keystroke "c" using command down'
|
|
1094
|
+
);
|
|
818
1095
|
await new Promise((r) => setTimeout(r, 300));
|
|
819
1096
|
content = await runShell("pbpaste");
|
|
820
1097
|
method = "clipboard";
|
|
821
|
-
await runAppleScript(
|
|
1098
|
+
await runAppleScript(
|
|
1099
|
+
'tell application "System Events" to key code 53'
|
|
1100
|
+
);
|
|
822
1101
|
if (savedClipboard && savedClipboard !== content) {
|
|
823
1102
|
execSync(`echo ${JSON.stringify(savedClipboard)} | pbcopy`);
|
|
824
1103
|
}
|
|
825
1104
|
} catch (clipErr) {
|
|
826
|
-
return {
|
|
1105
|
+
return {
|
|
1106
|
+
success: false,
|
|
1107
|
+
error: `Could not read page. Enable 'Allow JavaScript from Apple Events' in ${browser} or grant Accessibility permission. Error: ${clipErr.message}`
|
|
1108
|
+
};
|
|
827
1109
|
}
|
|
828
1110
|
}
|
|
829
1111
|
let pageUrl = "";
|
|
830
1112
|
let pageTitle = "";
|
|
831
1113
|
try {
|
|
832
1114
|
if (browser === "Safari") {
|
|
833
|
-
pageUrl = await runAppleScript(
|
|
834
|
-
|
|
1115
|
+
pageUrl = await runAppleScript(
|
|
1116
|
+
'tell application "Safari" to return URL of front document'
|
|
1117
|
+
);
|
|
1118
|
+
pageTitle = await runAppleScript(
|
|
1119
|
+
'tell application "Safari" to return name of front document'
|
|
1120
|
+
);
|
|
835
1121
|
} else {
|
|
836
|
-
pageUrl = await runAppleScript(
|
|
837
|
-
|
|
1122
|
+
pageUrl = await runAppleScript(
|
|
1123
|
+
`tell application "${browser.replace(/"/g, '\\"')}" to return URL of active tab of front window`
|
|
1124
|
+
);
|
|
1125
|
+
pageTitle = await runAppleScript(
|
|
1126
|
+
`tell application "${browser.replace(/"/g, '\\"')}" to return title of active tab of front window`
|
|
1127
|
+
);
|
|
838
1128
|
}
|
|
839
1129
|
} catch {
|
|
840
1130
|
}
|
|
@@ -857,13 +1147,20 @@ print("\\(x),\\(y)")`;
|
|
|
857
1147
|
try {
|
|
858
1148
|
let result;
|
|
859
1149
|
if (browser === "Safari") {
|
|
860
|
-
result = await runAppleScript(
|
|
1150
|
+
result = await runAppleScript(
|
|
1151
|
+
`tell application "Safari" to do JavaScript ${JSON.stringify(js)} in front document`
|
|
1152
|
+
);
|
|
861
1153
|
} else {
|
|
862
|
-
result = await runAppleScript(
|
|
1154
|
+
result = await runAppleScript(
|
|
1155
|
+
`tell application "${browser.replace(/"/g, '\\"')}" to execute javascript ${JSON.stringify(js)} in active tab of front window`
|
|
1156
|
+
);
|
|
863
1157
|
}
|
|
864
1158
|
return { success: true, data: { result: result.slice(0, 5e3) } };
|
|
865
1159
|
} catch (err) {
|
|
866
|
-
return {
|
|
1160
|
+
return {
|
|
1161
|
+
success: false,
|
|
1162
|
+
error: `JS execution failed (enable 'Allow JavaScript from Apple Events' in browser): ${err.message}`
|
|
1163
|
+
};
|
|
867
1164
|
}
|
|
868
1165
|
}
|
|
869
1166
|
// ── Email ──────────────────────────────────────────────
|
|
@@ -872,11 +1169,20 @@ print("\\(x),\\(y)")`;
|
|
|
872
1169
|
const subject = params.subject;
|
|
873
1170
|
const body = params.body;
|
|
874
1171
|
const method = params.method || "mail";
|
|
875
|
-
if (!to || !subject || !body)
|
|
1172
|
+
if (!to || !subject || !body)
|
|
1173
|
+
return { success: false, error: "Missing to, subject, or body" };
|
|
876
1174
|
if (method === "gmail") {
|
|
877
1175
|
const gmailUrl = `https://mail.google.com/mail/u/0/?view=cm&fs=1&to=${encodeURIComponent(to)}&su=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
|
878
1176
|
await runShell(`open "${gmailUrl}"`);
|
|
879
|
-
return {
|
|
1177
|
+
return {
|
|
1178
|
+
success: true,
|
|
1179
|
+
data: {
|
|
1180
|
+
method: "gmail",
|
|
1181
|
+
to,
|
|
1182
|
+
subject,
|
|
1183
|
+
note: "Gmail compose opened. User needs to click Send."
|
|
1184
|
+
}
|
|
1185
|
+
};
|
|
880
1186
|
}
|
|
881
1187
|
try {
|
|
882
1188
|
await runAppleScript(`
|
|
@@ -887,11 +1193,22 @@ print("\\(x),\\(y)")`;
|
|
|
887
1193
|
end tell
|
|
888
1194
|
send newMessage
|
|
889
1195
|
end tell`);
|
|
890
|
-
return {
|
|
1196
|
+
return {
|
|
1197
|
+
success: true,
|
|
1198
|
+
data: { method: "mail", to, subject, sent: true }
|
|
1199
|
+
};
|
|
891
1200
|
} catch (err) {
|
|
892
1201
|
const gmailUrl = `https://mail.google.com/mail/u/0/?view=cm&fs=1&to=${encodeURIComponent(to)}&su=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
|
893
1202
|
await runShell(`open "${gmailUrl}"`);
|
|
894
|
-
return {
|
|
1203
|
+
return {
|
|
1204
|
+
success: true,
|
|
1205
|
+
data: {
|
|
1206
|
+
method: "gmail_fallback",
|
|
1207
|
+
to,
|
|
1208
|
+
subject,
|
|
1209
|
+
note: `Mail.app failed (${err.message}). Gmail compose opened instead.`
|
|
1210
|
+
}
|
|
1211
|
+
};
|
|
895
1212
|
}
|
|
896
1213
|
}
|
|
897
1214
|
case "sys_run_shortcut": {
|
|
@@ -899,21 +1216,44 @@ print("\\(x),\\(y)")`;
|
|
|
899
1216
|
const input = params.input;
|
|
900
1217
|
if (!name) return { success: false, error: "Missing shortcut name" };
|
|
901
1218
|
const inputFlag = input ? `--input-type text --input "${input.replace(/"/g, '\\"')}"` : "";
|
|
902
|
-
const result = await runShell(
|
|
903
|
-
|
|
1219
|
+
const result = await runShell(
|
|
1220
|
+
`shortcuts run "${name.replace(/"/g, '\\"')}" ${inputFlag}`,
|
|
1221
|
+
3e4
|
|
1222
|
+
);
|
|
1223
|
+
return {
|
|
1224
|
+
success: true,
|
|
1225
|
+
data: { shortcut: name, output: result || "Shortcut executed" }
|
|
1226
|
+
};
|
|
904
1227
|
}
|
|
905
1228
|
// ── Shell Execution ─────────────────────────────────────
|
|
906
1229
|
case "sys_shell": {
|
|
907
1230
|
const cmd = params.command;
|
|
908
1231
|
if (!cmd) return { success: false, error: "Missing command" };
|
|
909
|
-
const blocked = [
|
|
910
|
-
|
|
1232
|
+
const blocked = [
|
|
1233
|
+
"rm -rf /",
|
|
1234
|
+
"mkfs",
|
|
1235
|
+
"dd if=",
|
|
1236
|
+
"> /dev/",
|
|
1237
|
+
":(){ :|:& };:"
|
|
1238
|
+
];
|
|
1239
|
+
if (blocked.some((b) => cmd.includes(b)))
|
|
1240
|
+
return { success: false, error: "Command blocked for safety" };
|
|
911
1241
|
const timeout = Number(params.timeout) || 15e3;
|
|
912
1242
|
try {
|
|
913
1243
|
const output = await runShell(cmd, timeout);
|
|
914
|
-
return {
|
|
1244
|
+
return {
|
|
1245
|
+
success: true,
|
|
1246
|
+
data: {
|
|
1247
|
+
command: cmd,
|
|
1248
|
+
output: output.slice(0, 1e4),
|
|
1249
|
+
truncated: output.length > 1e4
|
|
1250
|
+
}
|
|
1251
|
+
};
|
|
915
1252
|
} catch (err) {
|
|
916
|
-
return {
|
|
1253
|
+
return {
|
|
1254
|
+
success: false,
|
|
1255
|
+
error: `Shell error: ${err.message.slice(0, 2e3)}`
|
|
1256
|
+
};
|
|
917
1257
|
}
|
|
918
1258
|
}
|
|
919
1259
|
// ── Calendar ────────────────────────────────────────────
|
|
@@ -937,9 +1277,17 @@ print("\\(x),\\(y)")`;
|
|
|
937
1277
|
const raw = await runAppleScript(script);
|
|
938
1278
|
const events = raw.split("\n").filter(Boolean).map((line) => {
|
|
939
1279
|
const [cal, summary, start, end] = line.split(" | ");
|
|
940
|
-
return {
|
|
1280
|
+
return {
|
|
1281
|
+
calendar: cal?.trim(),
|
|
1282
|
+
summary: summary?.trim(),
|
|
1283
|
+
start: start?.trim(),
|
|
1284
|
+
end: end?.trim()
|
|
1285
|
+
};
|
|
941
1286
|
});
|
|
942
|
-
return {
|
|
1287
|
+
return {
|
|
1288
|
+
success: true,
|
|
1289
|
+
data: { events, count: events.length, daysAhead: days }
|
|
1290
|
+
};
|
|
943
1291
|
}
|
|
944
1292
|
case "sys_calendar_create": {
|
|
945
1293
|
const summary = params.summary || params.title;
|
|
@@ -947,7 +1295,8 @@ print("\\(x),\\(y)")`;
|
|
|
947
1295
|
const endStr = params.end;
|
|
948
1296
|
const calendar = params.calendar || "";
|
|
949
1297
|
const notes = params.notes || "";
|
|
950
|
-
if (!summary || !startStr)
|
|
1298
|
+
if (!summary || !startStr)
|
|
1299
|
+
return { success: false, error: "Missing summary or start time" };
|
|
951
1300
|
const calTarget = calendar ? `calendar "${calendar.replace(/"/g, '\\"')}"` : "default calendar";
|
|
952
1301
|
const endPart = endStr ? `set end date of newEvent to date "${endStr}"` : "";
|
|
953
1302
|
const notesPart = notes ? `set description of newEvent to "${notes.replace(/"/g, '\\"')}"` : "";
|
|
@@ -959,7 +1308,10 @@ print("\\(x),\\(y)")`;
|
|
|
959
1308
|
${notesPart}
|
|
960
1309
|
end tell
|
|
961
1310
|
end tell`);
|
|
962
|
-
return {
|
|
1311
|
+
return {
|
|
1312
|
+
success: true,
|
|
1313
|
+
data: { created: summary, start: startStr, end: endStr || "auto" }
|
|
1314
|
+
};
|
|
963
1315
|
}
|
|
964
1316
|
// ── Reminders ───────────────────────────────────────────
|
|
965
1317
|
case "sys_reminder_list": {
|
|
@@ -983,18 +1335,33 @@ print("\\(x),\\(y)")`;
|
|
|
983
1335
|
const raw = await runAppleScript(script);
|
|
984
1336
|
const reminders = raw.split("\n").filter(Boolean).map((line) => {
|
|
985
1337
|
const parts = line.split(" | ");
|
|
986
|
-
return parts.length === 3 ? {
|
|
1338
|
+
return parts.length === 3 ? {
|
|
1339
|
+
list: parts[0]?.trim(),
|
|
1340
|
+
name: parts[1]?.trim(),
|
|
1341
|
+
due: parts[2]?.trim()
|
|
1342
|
+
} : { name: parts[0]?.trim(), due: parts[1]?.trim() };
|
|
987
1343
|
});
|
|
988
|
-
return {
|
|
1344
|
+
return {
|
|
1345
|
+
success: true,
|
|
1346
|
+
data: { reminders, count: reminders.length }
|
|
1347
|
+
};
|
|
989
1348
|
} catch {
|
|
990
|
-
return {
|
|
1349
|
+
return {
|
|
1350
|
+
success: true,
|
|
1351
|
+
data: {
|
|
1352
|
+
reminders: [],
|
|
1353
|
+
count: 0,
|
|
1354
|
+
note: "No reminders or Reminders app not accessible"
|
|
1355
|
+
}
|
|
1356
|
+
};
|
|
991
1357
|
}
|
|
992
1358
|
}
|
|
993
1359
|
case "sys_reminder_create": {
|
|
994
1360
|
const reminderName = params.name;
|
|
995
1361
|
const dueDate = params.due;
|
|
996
1362
|
const listName2 = params.list || "Reminders";
|
|
997
|
-
if (!reminderName)
|
|
1363
|
+
if (!reminderName)
|
|
1364
|
+
return { success: false, error: "Missing reminder name" };
|
|
998
1365
|
const duePart = dueDate ? `, due date:date "${dueDate}"` : "";
|
|
999
1366
|
await runAppleScript(`
|
|
1000
1367
|
tell application "Reminders"
|
|
@@ -1002,20 +1369,34 @@ print("\\(x),\\(y)")`;
|
|
|
1002
1369
|
make new reminder with properties {name:"${reminderName.replace(/"/g, '\\"')}"${duePart}}
|
|
1003
1370
|
end tell
|
|
1004
1371
|
end tell`);
|
|
1005
|
-
return {
|
|
1372
|
+
return {
|
|
1373
|
+
success: true,
|
|
1374
|
+
data: {
|
|
1375
|
+
created: reminderName,
|
|
1376
|
+
due: dueDate || "none",
|
|
1377
|
+
list: listName2
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1006
1380
|
}
|
|
1007
1381
|
// ── iMessage ────────────────────────────────────────────
|
|
1008
1382
|
case "sys_imessage_send": {
|
|
1009
1383
|
const to2 = params.to;
|
|
1010
1384
|
const msg = params.message;
|
|
1011
|
-
if (!to2 || !msg)
|
|
1385
|
+
if (!to2 || !msg)
|
|
1386
|
+
return {
|
|
1387
|
+
success: false,
|
|
1388
|
+
error: "Missing 'to' (phone/email) or 'message'"
|
|
1389
|
+
};
|
|
1012
1390
|
await runAppleScript(`
|
|
1013
1391
|
tell application "Messages"
|
|
1014
1392
|
set targetService to 1st account whose service type = iMessage
|
|
1015
1393
|
set targetBuddy to participant "${to2.replace(/"/g, '\\"')}" of targetService
|
|
1016
1394
|
send "${msg.replace(/"/g, '\\"')}" to targetBuddy
|
|
1017
1395
|
end tell`);
|
|
1018
|
-
return {
|
|
1396
|
+
return {
|
|
1397
|
+
success: true,
|
|
1398
|
+
data: { sent: true, to: to2, message: msg.slice(0, 100) }
|
|
1399
|
+
};
|
|
1019
1400
|
}
|
|
1020
1401
|
// ── System Info ─────────────────────────────────────────
|
|
1021
1402
|
case "sys_system_info": {
|
|
@@ -1026,7 +1407,9 @@ print("\\(x),\\(y)")`;
|
|
|
1026
1407
|
info.battery = "N/A";
|
|
1027
1408
|
}
|
|
1028
1409
|
try {
|
|
1029
|
-
info.wifi = (await runShell(
|
|
1410
|
+
info.wifi = (await runShell(
|
|
1411
|
+
"networksetup -getairportnetwork en0 2>/dev/null | cut -d: -f2"
|
|
1412
|
+
)).trim();
|
|
1030
1413
|
} catch {
|
|
1031
1414
|
info.wifi = "N/A";
|
|
1032
1415
|
}
|
|
@@ -1041,7 +1424,9 @@ print("\\(x),\\(y)")`;
|
|
|
1041
1424
|
info.ip_public = "N/A";
|
|
1042
1425
|
}
|
|
1043
1426
|
try {
|
|
1044
|
-
info.disk = (await runShell(
|
|
1427
|
+
info.disk = (await runShell(
|
|
1428
|
+
`df -h / | tail -1 | awk '{print $3 " used / " $2 " total (" $5 " used)"}'`
|
|
1429
|
+
)).trim();
|
|
1045
1430
|
} catch {
|
|
1046
1431
|
info.disk = "N/A";
|
|
1047
1432
|
}
|
|
@@ -1080,7 +1465,9 @@ print("\\(x),\\(y)")`;
|
|
|
1080
1465
|
await runAppleScript(`set volume output volume ${vol}`);
|
|
1081
1466
|
return { success: true, data: { volume: vol } };
|
|
1082
1467
|
}
|
|
1083
|
-
const raw2 = await runAppleScript(
|
|
1468
|
+
const raw2 = await runAppleScript(
|
|
1469
|
+
"output volume of (get volume settings)"
|
|
1470
|
+
);
|
|
1084
1471
|
return { success: true, data: { volume: Number(raw2) || 0 } };
|
|
1085
1472
|
}
|
|
1086
1473
|
// ── Brightness ──────────────────────────────────────────
|
|
@@ -1088,14 +1475,27 @@ print("\\(x),\\(y)")`;
|
|
|
1088
1475
|
const level2 = params.level;
|
|
1089
1476
|
if (level2 !== void 0) {
|
|
1090
1477
|
const br = Math.max(0, Math.min(1, Number(level2)));
|
|
1091
|
-
await runShell(
|
|
1478
|
+
await runShell(
|
|
1479
|
+
`brightness ${br} 2>/dev/null || osascript -e 'tell application "System Events" to tell appearance preferences to set dark mode to ${br < 0.3}'`
|
|
1480
|
+
);
|
|
1092
1481
|
return { success: true, data: { brightness: br } };
|
|
1093
1482
|
}
|
|
1094
1483
|
try {
|
|
1095
|
-
const raw3 = await runShell(
|
|
1096
|
-
|
|
1484
|
+
const raw3 = await runShell(
|
|
1485
|
+
"brightness -l 2>/dev/null | grep brightness | head -1 | awk '{print $NF}'"
|
|
1486
|
+
);
|
|
1487
|
+
return {
|
|
1488
|
+
success: true,
|
|
1489
|
+
data: { brightness: parseFloat(raw3) || 0.5 }
|
|
1490
|
+
};
|
|
1097
1491
|
} catch {
|
|
1098
|
-
return {
|
|
1492
|
+
return {
|
|
1493
|
+
success: true,
|
|
1494
|
+
data: {
|
|
1495
|
+
brightness: "unknown",
|
|
1496
|
+
note: "Install 'brightness' via brew for control"
|
|
1497
|
+
}
|
|
1498
|
+
};
|
|
1099
1499
|
}
|
|
1100
1500
|
}
|
|
1101
1501
|
// ── Do Not Disturb ──────────────────────────────────────
|
|
@@ -1103,49 +1503,79 @@ print("\\(x),\\(y)")`;
|
|
|
1103
1503
|
const enabled = params.enabled;
|
|
1104
1504
|
if (enabled !== void 0) {
|
|
1105
1505
|
try {
|
|
1106
|
-
await runShell(
|
|
1107
|
-
|
|
1506
|
+
await runShell(
|
|
1507
|
+
`shortcuts run "Toggle Do Not Disturb" 2>/dev/null || osascript -e 'do shell script "defaults write com.apple.ncprefs dnd_prefs -data 0"'`
|
|
1508
|
+
);
|
|
1509
|
+
return {
|
|
1510
|
+
success: true,
|
|
1511
|
+
data: { dnd: enabled, note: "DND toggled" }
|
|
1512
|
+
};
|
|
1108
1513
|
} catch {
|
|
1109
|
-
return {
|
|
1514
|
+
return {
|
|
1515
|
+
success: true,
|
|
1516
|
+
data: {
|
|
1517
|
+
dnd: enabled,
|
|
1518
|
+
note: "Set DND manually in Control Center"
|
|
1519
|
+
}
|
|
1520
|
+
};
|
|
1110
1521
|
}
|
|
1111
1522
|
}
|
|
1112
|
-
return {
|
|
1523
|
+
return {
|
|
1524
|
+
success: true,
|
|
1525
|
+
data: { note: "Pass enabled: true/false to toggle DND" }
|
|
1526
|
+
};
|
|
1113
1527
|
}
|
|
1114
1528
|
// ── File Management ─────────────────────────────────────
|
|
1115
1529
|
case "sys_file_list": {
|
|
1116
1530
|
const dirPath = params.path || "Desktop";
|
|
1117
1531
|
const fullDir = safePath(dirPath);
|
|
1118
|
-
if (!fullDir)
|
|
1119
|
-
|
|
1532
|
+
if (!fullDir)
|
|
1533
|
+
return { success: false, error: `Access denied: ${dirPath}` };
|
|
1534
|
+
if (!existsSync(fullDir))
|
|
1535
|
+
return { success: false, error: `Directory not found: ${dirPath}` };
|
|
1120
1536
|
const entries = readdirSync(fullDir).map((name) => {
|
|
1121
1537
|
try {
|
|
1122
1538
|
const st = statSync(join(fullDir, name));
|
|
1123
|
-
return {
|
|
1539
|
+
return {
|
|
1540
|
+
name,
|
|
1541
|
+
type: st.isDirectory() ? "dir" : "file",
|
|
1542
|
+
size: st.size,
|
|
1543
|
+
modified: st.mtime.toISOString()
|
|
1544
|
+
};
|
|
1124
1545
|
} catch {
|
|
1125
1546
|
return { name, type: "unknown", size: 0, modified: "" };
|
|
1126
1547
|
}
|
|
1127
1548
|
});
|
|
1128
|
-
return {
|
|
1549
|
+
return {
|
|
1550
|
+
success: true,
|
|
1551
|
+
data: { path: dirPath, entries, count: entries.length }
|
|
1552
|
+
};
|
|
1129
1553
|
}
|
|
1130
1554
|
case "sys_file_move": {
|
|
1131
1555
|
const src = params.from;
|
|
1132
1556
|
const dst = params.to;
|
|
1133
|
-
if (!src || !dst)
|
|
1557
|
+
if (!src || !dst)
|
|
1558
|
+
return { success: false, error: "Missing from/to paths" };
|
|
1134
1559
|
const fullSrc = safePath(src);
|
|
1135
1560
|
const fullDst = safePath(dst);
|
|
1136
|
-
if (!fullSrc || !fullDst)
|
|
1137
|
-
|
|
1561
|
+
if (!fullSrc || !fullDst)
|
|
1562
|
+
return { success: false, error: "Access denied" };
|
|
1563
|
+
if (!existsSync(fullSrc))
|
|
1564
|
+
return { success: false, error: `Source not found: ${src}` };
|
|
1138
1565
|
renameSync(fullSrc, fullDst);
|
|
1139
1566
|
return { success: true, data: { moved: src, to: dst } };
|
|
1140
1567
|
}
|
|
1141
1568
|
case "sys_file_copy": {
|
|
1142
1569
|
const src2 = params.from;
|
|
1143
1570
|
const dst2 = params.to;
|
|
1144
|
-
if (!src2 || !dst2)
|
|
1571
|
+
if (!src2 || !dst2)
|
|
1572
|
+
return { success: false, error: "Missing from/to paths" };
|
|
1145
1573
|
const fullSrc2 = safePath(src2);
|
|
1146
1574
|
const fullDst2 = safePath(dst2);
|
|
1147
|
-
if (!fullSrc2 || !fullDst2)
|
|
1148
|
-
|
|
1575
|
+
if (!fullSrc2 || !fullDst2)
|
|
1576
|
+
return { success: false, error: "Access denied" };
|
|
1577
|
+
if (!existsSync(fullSrc2))
|
|
1578
|
+
return { success: false, error: `Source not found: ${src2}` };
|
|
1149
1579
|
copyFileSync(fullSrc2, fullDst2);
|
|
1150
1580
|
return { success: true, data: { copied: src2, to: dst2 } };
|
|
1151
1581
|
}
|
|
@@ -1154,16 +1584,23 @@ print("\\(x),\\(y)")`;
|
|
|
1154
1584
|
if (!target) return { success: false, error: "Missing path" };
|
|
1155
1585
|
const fullTarget = safePath(target);
|
|
1156
1586
|
if (!fullTarget) return { success: false, error: "Access denied" };
|
|
1157
|
-
if (!existsSync(fullTarget))
|
|
1158
|
-
|
|
1159
|
-
|
|
1587
|
+
if (!existsSync(fullTarget))
|
|
1588
|
+
return { success: false, error: `Not found: ${target}` };
|
|
1589
|
+
await runShell(
|
|
1590
|
+
`osascript -e 'tell application "Finder" to delete POSIX file "${fullTarget}"'`
|
|
1591
|
+
);
|
|
1592
|
+
return {
|
|
1593
|
+
success: true,
|
|
1594
|
+
data: { deleted: target, method: "moved_to_trash" }
|
|
1595
|
+
};
|
|
1160
1596
|
}
|
|
1161
1597
|
case "sys_file_info": {
|
|
1162
1598
|
const fPath = params.path;
|
|
1163
1599
|
if (!fPath) return { success: false, error: "Missing path" };
|
|
1164
1600
|
const fullF = safePath(fPath);
|
|
1165
1601
|
if (!fullF) return { success: false, error: "Access denied" };
|
|
1166
|
-
if (!existsSync(fullF))
|
|
1602
|
+
if (!existsSync(fullF))
|
|
1603
|
+
return { success: false, error: `Not found: ${fPath}` };
|
|
1167
1604
|
const st = statSync(fullF);
|
|
1168
1605
|
return {
|
|
1169
1606
|
success: true,
|
|
@@ -1186,9 +1623,15 @@ print("\\(x),\\(y)")`;
|
|
|
1186
1623
|
if (!dlUrl) return { success: false, error: "Missing URL" };
|
|
1187
1624
|
const fullDl = safePath(dlDest);
|
|
1188
1625
|
if (!fullDl) return { success: false, error: "Access denied" };
|
|
1189
|
-
await runShell(
|
|
1626
|
+
await runShell(
|
|
1627
|
+
`curl -sL -o "${fullDl}" "${dlUrl.replace(/"/g, '\\"')}"`,
|
|
1628
|
+
6e4
|
|
1629
|
+
);
|
|
1190
1630
|
const size = existsSync(fullDl) ? statSync(fullDl).size : 0;
|
|
1191
|
-
return {
|
|
1631
|
+
return {
|
|
1632
|
+
success: true,
|
|
1633
|
+
data: { downloaded: dlUrl, saved: dlDest, size }
|
|
1634
|
+
};
|
|
1192
1635
|
}
|
|
1193
1636
|
// ── Window Management ───────────────────────────────────
|
|
1194
1637
|
case "sys_window_list": {
|
|
@@ -1205,7 +1648,12 @@ print("\\(x),\\(y)")`;
|
|
|
1205
1648
|
end tell`);
|
|
1206
1649
|
const windows = raw4.split("\n").filter(Boolean).map((line) => {
|
|
1207
1650
|
const [app, title, pos, sz] = line.split(" | ");
|
|
1208
|
-
return {
|
|
1651
|
+
return {
|
|
1652
|
+
app: app?.trim(),
|
|
1653
|
+
title: title?.trim(),
|
|
1654
|
+
position: pos?.trim(),
|
|
1655
|
+
size: sz?.trim()
|
|
1656
|
+
};
|
|
1209
1657
|
});
|
|
1210
1658
|
return { success: true, data: { windows, count: windows.length } };
|
|
1211
1659
|
}
|
|
@@ -1228,7 +1676,11 @@ print("\\(x),\\(y)")`;
|
|
|
1228
1676
|
if (!app2) return { success: false, error: "Missing app name" };
|
|
1229
1677
|
const posPart = x !== void 0 && y !== void 0 ? `set position of window 1 to {${x}, ${y}}` : "";
|
|
1230
1678
|
const sizePart = w !== void 0 && h !== void 0 ? `set size of window 1 to {${w}, ${h}}` : "";
|
|
1231
|
-
if (!posPart && !sizePart)
|
|
1679
|
+
if (!posPart && !sizePart)
|
|
1680
|
+
return {
|
|
1681
|
+
success: false,
|
|
1682
|
+
error: "Provide x,y for position and/or width,height for size"
|
|
1683
|
+
};
|
|
1232
1684
|
await runAppleScript(`
|
|
1233
1685
|
tell application "System Events"
|
|
1234
1686
|
tell process "${app2.replace(/"/g, '\\"')}"
|
|
@@ -1236,7 +1688,14 @@ print("\\(x),\\(y)")`;
|
|
|
1236
1688
|
${sizePart}
|
|
1237
1689
|
end tell
|
|
1238
1690
|
end tell`);
|
|
1239
|
-
return {
|
|
1691
|
+
return {
|
|
1692
|
+
success: true,
|
|
1693
|
+
data: {
|
|
1694
|
+
app: app2,
|
|
1695
|
+
position: posPart ? { x, y } : "unchanged",
|
|
1696
|
+
size: sizePart ? { width: w, height: h } : "unchanged"
|
|
1697
|
+
}
|
|
1698
|
+
};
|
|
1240
1699
|
}
|
|
1241
1700
|
// ── Apple Notes ─────────────────────────────────────────
|
|
1242
1701
|
case "sys_notes_list": {
|
|
@@ -1252,7 +1711,11 @@ print("\\(x),\\(y)")`;
|
|
|
1252
1711
|
end tell`);
|
|
1253
1712
|
const notes = raw5.split("\n").filter(Boolean).map((line) => {
|
|
1254
1713
|
const [id, name, date] = line.split(" | ");
|
|
1255
|
-
return {
|
|
1714
|
+
return {
|
|
1715
|
+
id: id?.trim(),
|
|
1716
|
+
name: name?.trim(),
|
|
1717
|
+
modified: date?.trim()
|
|
1718
|
+
};
|
|
1256
1719
|
});
|
|
1257
1720
|
return { success: true, data: { notes, count: notes.length } };
|
|
1258
1721
|
}
|
|
@@ -1292,9 +1755,16 @@ print("\\(x),\\(y)")`;
|
|
|
1292
1755
|
end tell`);
|
|
1293
1756
|
const contacts = raw6.split("\n").filter(Boolean).map((line) => {
|
|
1294
1757
|
const [name, email, phone] = line.split(" | ");
|
|
1295
|
-
return {
|
|
1758
|
+
return {
|
|
1759
|
+
name: name?.trim(),
|
|
1760
|
+
email: email?.trim(),
|
|
1761
|
+
phone: phone?.trim()
|
|
1762
|
+
};
|
|
1296
1763
|
});
|
|
1297
|
-
return {
|
|
1764
|
+
return {
|
|
1765
|
+
success: true,
|
|
1766
|
+
data: { contacts, count: contacts.length, query: query2 }
|
|
1767
|
+
};
|
|
1298
1768
|
}
|
|
1299
1769
|
// ── OCR (Vision framework) ──────────────────────────────
|
|
1300
1770
|
case "sys_ocr": {
|
|
@@ -1302,7 +1772,8 @@ print("\\(x),\\(y)")`;
|
|
|
1302
1772
|
if (!imgPath) return { success: false, error: "Missing image path" };
|
|
1303
1773
|
const fullImg = imgPath.startsWith("/tmp/") ? imgPath : safePath(imgPath);
|
|
1304
1774
|
if (!fullImg) return { success: false, error: "Access denied" };
|
|
1305
|
-
if (!existsSync(fullImg))
|
|
1775
|
+
if (!existsSync(fullImg))
|
|
1776
|
+
return { success: false, error: `Image not found: ${imgPath}` };
|
|
1306
1777
|
const swiftOcr = `
|
|
1307
1778
|
import Foundation
|
|
1308
1779
|
import Vision
|
|
@@ -1324,10 +1795,311 @@ let text = results.compactMap { $0.topCandidates(1).first?.string }.joined(separ
|
|
|
1324
1795
|
print(text)`;
|
|
1325
1796
|
try {
|
|
1326
1797
|
const ocrText = await runSwift(swiftOcr, 3e4);
|
|
1327
|
-
return {
|
|
1798
|
+
return {
|
|
1799
|
+
success: true,
|
|
1800
|
+
data: {
|
|
1801
|
+
text: ocrText.slice(0, 1e4),
|
|
1802
|
+
length: ocrText.length,
|
|
1803
|
+
path: imgPath
|
|
1804
|
+
}
|
|
1805
|
+
};
|
|
1328
1806
|
} catch (err) {
|
|
1329
|
-
return {
|
|
1807
|
+
return {
|
|
1808
|
+
success: false,
|
|
1809
|
+
error: `OCR failed: ${err.message}`
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
// ── Philips Hue (Local Bridge API) ─────────────────────
|
|
1814
|
+
// Commands are prefixed with sys_hue_ (mapped from hue_ in chat.ts → system_hue_ → sys_hue_)
|
|
1815
|
+
case "sys_hue_lights_on": {
|
|
1816
|
+
const light = params.light;
|
|
1817
|
+
if (!light)
|
|
1818
|
+
return { success: false, error: "Missing light ID or name" };
|
|
1819
|
+
const hueConfig = await getHueConfig();
|
|
1820
|
+
if (!hueConfig)
|
|
1821
|
+
return {
|
|
1822
|
+
success: false,
|
|
1823
|
+
error: "Philips Hue not configured. Set HUE_BRIDGE_IP and HUE_USERNAME environment variables, or configure in Pulso Settings."
|
|
1824
|
+
};
|
|
1825
|
+
const lightId = await resolveHueLight(hueConfig, light);
|
|
1826
|
+
if (!lightId)
|
|
1827
|
+
return {
|
|
1828
|
+
success: false,
|
|
1829
|
+
error: `Light '${light}' not found on Hue bridge`
|
|
1830
|
+
};
|
|
1831
|
+
const state = { on: true };
|
|
1832
|
+
if (params.brightness !== void 0)
|
|
1833
|
+
state.bri = Math.max(1, Math.min(254, Number(params.brightness)));
|
|
1834
|
+
if (params.color) {
|
|
1835
|
+
const rgb = parseColorCompanion(params.color);
|
|
1836
|
+
if (rgb) {
|
|
1837
|
+
const [x, y] = rgbToXyCompanion(...rgb);
|
|
1838
|
+
state.xy = [x, y];
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
const hueRes = await hueRequest(
|
|
1842
|
+
hueConfig,
|
|
1843
|
+
`lights/${lightId}/state`,
|
|
1844
|
+
"PUT",
|
|
1845
|
+
state
|
|
1846
|
+
);
|
|
1847
|
+
return {
|
|
1848
|
+
success: true,
|
|
1849
|
+
data: { light: lightId, action: "on", ...state, response: hueRes }
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
case "sys_hue_lights_off": {
|
|
1853
|
+
const light = params.light;
|
|
1854
|
+
if (!light)
|
|
1855
|
+
return { success: false, error: "Missing light ID or name" };
|
|
1856
|
+
const hueConfig = await getHueConfig();
|
|
1857
|
+
if (!hueConfig)
|
|
1858
|
+
return { success: false, error: "Philips Hue not configured." };
|
|
1859
|
+
const lightId = await resolveHueLight(hueConfig, light);
|
|
1860
|
+
if (!lightId)
|
|
1861
|
+
return { success: false, error: `Light '${light}' not found` };
|
|
1862
|
+
const hueRes = await hueRequest(
|
|
1863
|
+
hueConfig,
|
|
1864
|
+
`lights/${lightId}/state`,
|
|
1865
|
+
"PUT",
|
|
1866
|
+
{ on: false }
|
|
1867
|
+
);
|
|
1868
|
+
return {
|
|
1869
|
+
success: true,
|
|
1870
|
+
data: { light: lightId, action: "off", response: hueRes }
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
case "sys_hue_lights_color": {
|
|
1874
|
+
const light = params.light;
|
|
1875
|
+
const color = params.color;
|
|
1876
|
+
if (!light || !color)
|
|
1877
|
+
return { success: false, error: "Missing light or color" };
|
|
1878
|
+
const hueConfig = await getHueConfig();
|
|
1879
|
+
if (!hueConfig)
|
|
1880
|
+
return { success: false, error: "Philips Hue not configured." };
|
|
1881
|
+
const lightId = await resolveHueLight(hueConfig, light);
|
|
1882
|
+
if (!lightId)
|
|
1883
|
+
return { success: false, error: `Light '${light}' not found` };
|
|
1884
|
+
const rgb = parseColorCompanion(color);
|
|
1885
|
+
if (!rgb)
|
|
1886
|
+
return { success: false, error: `Unrecognized color: ${color}` };
|
|
1887
|
+
const [x, y] = rgbToXyCompanion(...rgb);
|
|
1888
|
+
const hueRes = await hueRequest(
|
|
1889
|
+
hueConfig,
|
|
1890
|
+
`lights/${lightId}/state`,
|
|
1891
|
+
"PUT",
|
|
1892
|
+
{ on: true, xy: [x, y] }
|
|
1893
|
+
);
|
|
1894
|
+
return {
|
|
1895
|
+
success: true,
|
|
1896
|
+
data: { light: lightId, color, xy: [x, y], response: hueRes }
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
case "sys_hue_lights_brightness": {
|
|
1900
|
+
const light = params.light;
|
|
1901
|
+
const brightness = Number(params.brightness);
|
|
1902
|
+
if (!light || isNaN(brightness))
|
|
1903
|
+
return { success: false, error: "Missing light or brightness" };
|
|
1904
|
+
const hueConfig = await getHueConfig();
|
|
1905
|
+
if (!hueConfig)
|
|
1906
|
+
return { success: false, error: "Philips Hue not configured." };
|
|
1907
|
+
const lightId = await resolveHueLight(hueConfig, light);
|
|
1908
|
+
if (!lightId)
|
|
1909
|
+
return { success: false, error: `Light '${light}' not found` };
|
|
1910
|
+
const bri = Math.max(1, Math.min(254, brightness));
|
|
1911
|
+
const hueRes = await hueRequest(
|
|
1912
|
+
hueConfig,
|
|
1913
|
+
`lights/${lightId}/state`,
|
|
1914
|
+
"PUT",
|
|
1915
|
+
{ on: true, bri }
|
|
1916
|
+
);
|
|
1917
|
+
return {
|
|
1918
|
+
success: true,
|
|
1919
|
+
data: { light: lightId, brightness: bri, response: hueRes }
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
case "sys_hue_lights_scene": {
|
|
1923
|
+
const scene = params.scene;
|
|
1924
|
+
const group = params.group || "0";
|
|
1925
|
+
if (!scene) return { success: false, error: "Missing scene name" };
|
|
1926
|
+
const hueConfig = await getHueConfig();
|
|
1927
|
+
if (!hueConfig)
|
|
1928
|
+
return { success: false, error: "Philips Hue not configured." };
|
|
1929
|
+
const scenes = await hueRequest(hueConfig, "scenes", "GET");
|
|
1930
|
+
let sceneId = null;
|
|
1931
|
+
for (const [id, s] of Object.entries(scenes)) {
|
|
1932
|
+
if (s.name?.toLowerCase() === scene.toLowerCase()) {
|
|
1933
|
+
sceneId = id;
|
|
1934
|
+
break;
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
if (!sceneId)
|
|
1938
|
+
return {
|
|
1939
|
+
success: false,
|
|
1940
|
+
error: `Scene '${scene}' not found. Available: ${Object.values(
|
|
1941
|
+
scenes
|
|
1942
|
+
).map((s) => s.name).join(", ")}`
|
|
1943
|
+
};
|
|
1944
|
+
const hueRes = await hueRequest(
|
|
1945
|
+
hueConfig,
|
|
1946
|
+
`groups/${group}/action`,
|
|
1947
|
+
"PUT",
|
|
1948
|
+
{ scene: sceneId }
|
|
1949
|
+
);
|
|
1950
|
+
return {
|
|
1951
|
+
success: true,
|
|
1952
|
+
data: { scene, sceneId, group, response: hueRes }
|
|
1953
|
+
};
|
|
1954
|
+
}
|
|
1955
|
+
case "sys_hue_lights_list": {
|
|
1956
|
+
const hueConfig = await getHueConfig();
|
|
1957
|
+
if (!hueConfig)
|
|
1958
|
+
return { success: false, error: "Philips Hue not configured." };
|
|
1959
|
+
const [lights, groups, scenes] = await Promise.all([
|
|
1960
|
+
hueRequest(hueConfig, "lights", "GET"),
|
|
1961
|
+
hueRequest(hueConfig, "groups", "GET"),
|
|
1962
|
+
hueRequest(hueConfig, "scenes", "GET")
|
|
1963
|
+
]);
|
|
1964
|
+
return {
|
|
1965
|
+
success: true,
|
|
1966
|
+
data: {
|
|
1967
|
+
lights: Object.entries(lights).map(([id, l]) => ({
|
|
1968
|
+
id,
|
|
1969
|
+
name: l.name,
|
|
1970
|
+
on: l.state?.on,
|
|
1971
|
+
brightness: l.state?.bri,
|
|
1972
|
+
type: l.type
|
|
1973
|
+
})),
|
|
1974
|
+
groups: Object.entries(groups).map(([id, g]) => ({
|
|
1975
|
+
id,
|
|
1976
|
+
name: g.name,
|
|
1977
|
+
type: g.type,
|
|
1978
|
+
lightCount: g.lights?.length
|
|
1979
|
+
})),
|
|
1980
|
+
scenes: Object.entries(scenes).map(([id, s]) => ({
|
|
1981
|
+
id,
|
|
1982
|
+
name: s.name,
|
|
1983
|
+
group: s.group
|
|
1984
|
+
}))
|
|
1985
|
+
}
|
|
1986
|
+
};
|
|
1987
|
+
}
|
|
1988
|
+
// ── Sonos (Local HTTP API) ──────────────────────────────
|
|
1989
|
+
case "sys_sonos_play": {
|
|
1990
|
+
const room = params.room;
|
|
1991
|
+
if (!room) return { success: false, error: "Missing room name" };
|
|
1992
|
+
const sonosUrl = getSonosApiUrl();
|
|
1993
|
+
if (!sonosUrl)
|
|
1994
|
+
return {
|
|
1995
|
+
success: false,
|
|
1996
|
+
error: "Sonos not configured. Set SONOS_API_URL environment variable (e.g., http://localhost:5005)."
|
|
1997
|
+
};
|
|
1998
|
+
const res = await sonosRequest(
|
|
1999
|
+
sonosUrl,
|
|
2000
|
+
`${encodeURIComponent(room)}/play`
|
|
2001
|
+
);
|
|
2002
|
+
return { success: true, data: { room, action: "play", response: res } };
|
|
2003
|
+
}
|
|
2004
|
+
case "sys_sonos_pause": {
|
|
2005
|
+
const room = params.room;
|
|
2006
|
+
if (!room) return { success: false, error: "Missing room name" };
|
|
2007
|
+
const sonosUrl = getSonosApiUrl();
|
|
2008
|
+
if (!sonosUrl)
|
|
2009
|
+
return { success: false, error: "Sonos not configured." };
|
|
2010
|
+
const res = await sonosRequest(
|
|
2011
|
+
sonosUrl,
|
|
2012
|
+
`${encodeURIComponent(room)}/pause`
|
|
2013
|
+
);
|
|
2014
|
+
return {
|
|
2015
|
+
success: true,
|
|
2016
|
+
data: { room, action: "pause", response: res }
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
case "sys_sonos_volume": {
|
|
2020
|
+
const room = params.room;
|
|
2021
|
+
const level = Number(params.level);
|
|
2022
|
+
if (!room || isNaN(level))
|
|
2023
|
+
return { success: false, error: "Missing room or level" };
|
|
2024
|
+
const sonosUrl = getSonosApiUrl();
|
|
2025
|
+
if (!sonosUrl)
|
|
2026
|
+
return { success: false, error: "Sonos not configured." };
|
|
2027
|
+
const vol = Math.max(0, Math.min(100, level));
|
|
2028
|
+
const res = await sonosRequest(
|
|
2029
|
+
sonosUrl,
|
|
2030
|
+
`${encodeURIComponent(room)}/volume/${vol}`
|
|
2031
|
+
);
|
|
2032
|
+
return { success: true, data: { room, volume: vol, response: res } };
|
|
2033
|
+
}
|
|
2034
|
+
case "sys_sonos_play_uri": {
|
|
2035
|
+
const room = params.room;
|
|
2036
|
+
const uri = params.uri;
|
|
2037
|
+
if (!room || !uri)
|
|
2038
|
+
return { success: false, error: "Missing room or URI" };
|
|
2039
|
+
const sonosUrl = getSonosApiUrl();
|
|
2040
|
+
if (!sonosUrl)
|
|
2041
|
+
return { success: false, error: "Sonos not configured." };
|
|
2042
|
+
if (uri.startsWith("spotify:")) {
|
|
2043
|
+
const res2 = await sonosRequest(
|
|
2044
|
+
sonosUrl,
|
|
2045
|
+
`${encodeURIComponent(room)}/spotify/now/${encodeURIComponent(uri)}`
|
|
2046
|
+
);
|
|
2047
|
+
return {
|
|
2048
|
+
success: true,
|
|
2049
|
+
data: { room, uri, type: "spotify", response: res2 }
|
|
2050
|
+
};
|
|
1330
2051
|
}
|
|
2052
|
+
const res = await sonosRequest(
|
|
2053
|
+
sonosUrl,
|
|
2054
|
+
`${encodeURIComponent(room)}/setavtransporturi/${encodeURIComponent(uri)}`
|
|
2055
|
+
);
|
|
2056
|
+
return { success: true, data: { room, uri, response: res } };
|
|
2057
|
+
}
|
|
2058
|
+
case "sys_sonos_rooms": {
|
|
2059
|
+
const sonosUrl = getSonosApiUrl();
|
|
2060
|
+
if (!sonosUrl)
|
|
2061
|
+
return { success: false, error: "Sonos not configured." };
|
|
2062
|
+
const res = await sonosRequest(sonosUrl, "zones");
|
|
2063
|
+
return { success: true, data: res };
|
|
2064
|
+
}
|
|
2065
|
+
case "sys_sonos_next": {
|
|
2066
|
+
const room = params.room;
|
|
2067
|
+
if (!room) return { success: false, error: "Missing room name" };
|
|
2068
|
+
const sonosUrl = getSonosApiUrl();
|
|
2069
|
+
if (!sonosUrl)
|
|
2070
|
+
return { success: false, error: "Sonos not configured." };
|
|
2071
|
+
const res = await sonosRequest(
|
|
2072
|
+
sonosUrl,
|
|
2073
|
+
`${encodeURIComponent(room)}/next`
|
|
2074
|
+
);
|
|
2075
|
+
return { success: true, data: { room, action: "next", response: res } };
|
|
2076
|
+
}
|
|
2077
|
+
case "sys_sonos_previous": {
|
|
2078
|
+
const room = params.room;
|
|
2079
|
+
if (!room) return { success: false, error: "Missing room name" };
|
|
2080
|
+
const sonosUrl = getSonosApiUrl();
|
|
2081
|
+
if (!sonosUrl)
|
|
2082
|
+
return { success: false, error: "Sonos not configured." };
|
|
2083
|
+
const res = await sonosRequest(
|
|
2084
|
+
sonosUrl,
|
|
2085
|
+
`${encodeURIComponent(room)}/previous`
|
|
2086
|
+
);
|
|
2087
|
+
return {
|
|
2088
|
+
success: true,
|
|
2089
|
+
data: { room, action: "previous", response: res }
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
case "sys_sonos_now_playing": {
|
|
2093
|
+
const room = params.room;
|
|
2094
|
+
if (!room) return { success: false, error: "Missing room name" };
|
|
2095
|
+
const sonosUrl = getSonosApiUrl();
|
|
2096
|
+
if (!sonosUrl)
|
|
2097
|
+
return { success: false, error: "Sonos not configured." };
|
|
2098
|
+
const res = await sonosRequest(
|
|
2099
|
+
sonosUrl,
|
|
2100
|
+
`${encodeURIComponent(room)}/state`
|
|
2101
|
+
);
|
|
2102
|
+
return { success: true, data: { room, ...res } };
|
|
1331
2103
|
}
|
|
1332
2104
|
default:
|
|
1333
2105
|
return { success: false, error: `Unknown command: ${command}` };
|
|
@@ -1336,6 +2108,130 @@ print(text)`;
|
|
|
1336
2108
|
return { success: false, error: err.message };
|
|
1337
2109
|
}
|
|
1338
2110
|
}
|
|
2111
|
+
var HUE_CONFIG_FILE = join(HOME, ".pulso-hue-config.json");
|
|
2112
|
+
async function getHueConfig() {
|
|
2113
|
+
const ip = process.env.HUE_BRIDGE_IP;
|
|
2114
|
+
const user = process.env.HUE_USERNAME;
|
|
2115
|
+
if (ip && user) return { bridgeIp: ip, username: user };
|
|
2116
|
+
try {
|
|
2117
|
+
if (existsSync(HUE_CONFIG_FILE)) {
|
|
2118
|
+
const data = JSON.parse(readFileSync(HUE_CONFIG_FILE, "utf-8"));
|
|
2119
|
+
if (data.bridgeIp && data.username) return data;
|
|
2120
|
+
}
|
|
2121
|
+
} catch {
|
|
2122
|
+
}
|
|
2123
|
+
return null;
|
|
2124
|
+
}
|
|
2125
|
+
async function hueRequest(config, path, method, body) {
|
|
2126
|
+
const url = `http://${config.bridgeIp}/api/${config.username}/${path}`;
|
|
2127
|
+
const controller = new AbortController();
|
|
2128
|
+
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
2129
|
+
try {
|
|
2130
|
+
const opts = {
|
|
2131
|
+
method,
|
|
2132
|
+
signal: controller.signal,
|
|
2133
|
+
headers: { "Content-Type": "application/json" }
|
|
2134
|
+
};
|
|
2135
|
+
if (body && method !== "GET") opts.body = JSON.stringify(body);
|
|
2136
|
+
const res = await fetch(url, opts);
|
|
2137
|
+
clearTimeout(timeout);
|
|
2138
|
+
return await res.json();
|
|
2139
|
+
} catch (err) {
|
|
2140
|
+
clearTimeout(timeout);
|
|
2141
|
+
throw new Error(
|
|
2142
|
+
`Hue bridge unreachable at ${config.bridgeIp}: ${err.message}`
|
|
2143
|
+
);
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
var hueLightCache = null;
|
|
2147
|
+
var hueLightCacheTs = 0;
|
|
2148
|
+
async function resolveHueLight(config, lightRef) {
|
|
2149
|
+
if (/^\d+$/.test(lightRef)) return lightRef;
|
|
2150
|
+
if (!hueLightCache || Date.now() - hueLightCacheTs > 3e5) {
|
|
2151
|
+
try {
|
|
2152
|
+
const lights = await hueRequest(config, "lights", "GET");
|
|
2153
|
+
hueLightCache = /* @__PURE__ */ new Map();
|
|
2154
|
+
for (const [id, light] of Object.entries(lights)) {
|
|
2155
|
+
hueLightCache.set(light.name.toLowerCase(), id);
|
|
2156
|
+
}
|
|
2157
|
+
hueLightCacheTs = Date.now();
|
|
2158
|
+
} catch {
|
|
2159
|
+
return null;
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
return hueLightCache.get(lightRef.toLowerCase()) ?? null;
|
|
2163
|
+
}
|
|
2164
|
+
var CSS_COLORS_COMPANION = {
|
|
2165
|
+
red: [255, 0, 0],
|
|
2166
|
+
green: [0, 128, 0],
|
|
2167
|
+
blue: [0, 0, 255],
|
|
2168
|
+
yellow: [255, 255, 0],
|
|
2169
|
+
orange: [255, 165, 0],
|
|
2170
|
+
purple: [128, 0, 128],
|
|
2171
|
+
pink: [255, 192, 203],
|
|
2172
|
+
white: [255, 255, 255],
|
|
2173
|
+
cyan: [0, 255, 255],
|
|
2174
|
+
magenta: [255, 0, 255],
|
|
2175
|
+
lime: [0, 255, 0],
|
|
2176
|
+
teal: [0, 128, 128],
|
|
2177
|
+
coral: [255, 127, 80],
|
|
2178
|
+
salmon: [250, 128, 114],
|
|
2179
|
+
gold: [255, 215, 0],
|
|
2180
|
+
lavender: [230, 230, 250],
|
|
2181
|
+
turquoise: [64, 224, 208],
|
|
2182
|
+
warmwhite: [255, 200, 150],
|
|
2183
|
+
coolwhite: [200, 220, 255]
|
|
2184
|
+
};
|
|
2185
|
+
function parseColorCompanion(color) {
|
|
2186
|
+
if (color.startsWith("#")) {
|
|
2187
|
+
const hex = color.slice(1);
|
|
2188
|
+
if (hex.length === 6) {
|
|
2189
|
+
return [
|
|
2190
|
+
parseInt(hex.slice(0, 2), 16),
|
|
2191
|
+
parseInt(hex.slice(2, 4), 16),
|
|
2192
|
+
parseInt(hex.slice(4, 6), 16)
|
|
2193
|
+
];
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
return CSS_COLORS_COMPANION[color.toLowerCase()] ?? null;
|
|
2197
|
+
}
|
|
2198
|
+
function rgbToXyCompanion(r, g, b) {
|
|
2199
|
+
let rr = r / 255;
|
|
2200
|
+
let gg = g / 255;
|
|
2201
|
+
let bb = b / 255;
|
|
2202
|
+
rr = rr > 0.04045 ? Math.pow((rr + 0.055) / 1.055, 2.4) : rr / 12.92;
|
|
2203
|
+
gg = gg > 0.04045 ? Math.pow((gg + 0.055) / 1.055, 2.4) : gg / 12.92;
|
|
2204
|
+
bb = bb > 0.04045 ? Math.pow((bb + 0.055) / 1.055, 2.4) : bb / 12.92;
|
|
2205
|
+
const X = rr * 0.664511 + gg * 0.154324 + bb * 0.162028;
|
|
2206
|
+
const Y = rr * 0.283881 + gg * 0.668433 + bb * 0.047685;
|
|
2207
|
+
const Z = rr * 88e-6 + gg * 0.07231 + bb * 0.986039;
|
|
2208
|
+
const sum = X + Y + Z;
|
|
2209
|
+
if (sum === 0) return [0.3127, 0.329];
|
|
2210
|
+
return [X / sum, Y / sum];
|
|
2211
|
+
}
|
|
2212
|
+
function getSonosApiUrl() {
|
|
2213
|
+
return process.env.SONOS_API_URL || null;
|
|
2214
|
+
}
|
|
2215
|
+
async function sonosRequest(baseUrl, path) {
|
|
2216
|
+
const url = `${baseUrl.replace(/\/$/, "")}/${path}`;
|
|
2217
|
+
const controller = new AbortController();
|
|
2218
|
+
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
2219
|
+
try {
|
|
2220
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
2221
|
+
clearTimeout(timeout);
|
|
2222
|
+
const text = await res.text();
|
|
2223
|
+
try {
|
|
2224
|
+
return JSON.parse(text);
|
|
2225
|
+
} catch {
|
|
2226
|
+
return { raw: text };
|
|
2227
|
+
}
|
|
2228
|
+
} catch (err) {
|
|
2229
|
+
clearTimeout(timeout);
|
|
2230
|
+
throw new Error(
|
|
2231
|
+
`Sonos API unreachable at ${baseUrl}: ${err.message}`
|
|
2232
|
+
);
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
1339
2235
|
var ws = null;
|
|
1340
2236
|
var reconnectTimer = null;
|
|
1341
2237
|
var heartbeatTimer = null;
|
|
@@ -1348,12 +2244,16 @@ function connect() {
|
|
|
1348
2244
|
ws.on("open", () => {
|
|
1349
2245
|
reconnectAttempts = 0;
|
|
1350
2246
|
console.log("\u2705 Connected to Pulso!");
|
|
1351
|
-
console.log(
|
|
2247
|
+
console.log(
|
|
2248
|
+
`\u{1F5A5}\uFE0F Companion is active \u2014 ${ACCESS_LEVEL === "full" ? "full device access" : "sandboxed mode"}`
|
|
2249
|
+
);
|
|
1352
2250
|
console.log("");
|
|
1353
2251
|
console.log(" Available capabilities:");
|
|
1354
2252
|
console.log(" \u2022 Open apps & URLs");
|
|
1355
2253
|
console.log(" \u2022 Control Spotify & media");
|
|
1356
|
-
console.log(
|
|
2254
|
+
console.log(
|
|
2255
|
+
` \u2022 Read/write files ${ACCESS_LEVEL === "full" ? "(full device)" : "(Documents, Desktop, Downloads)"}`
|
|
2256
|
+
);
|
|
1357
2257
|
console.log(" \u2022 Clipboard access");
|
|
1358
2258
|
console.log(" \u2022 Screenshots");
|
|
1359
2259
|
console.log(" \u2022 Text-to-speech");
|
|
@@ -1362,8 +2262,12 @@ function connect() {
|
|
|
1362
2262
|
console.log(" \u2022 Terminal commands");
|
|
1363
2263
|
console.log(" \u2022 System notifications");
|
|
1364
2264
|
console.log(" \u2022 macOS Shortcuts");
|
|
2265
|
+
console.log(" \u2022 Smart Home: Philips Hue (if configured)");
|
|
2266
|
+
console.log(" \u2022 Smart Home: Sonos (if configured)");
|
|
1365
2267
|
console.log("");
|
|
1366
|
-
console.log(
|
|
2268
|
+
console.log(
|
|
2269
|
+
` Access: ${ACCESS_LEVEL === "full" ? "\u{1F513} Full (unrestricted)" : "\u{1F512} Sandboxed (safe dirs only)"}`
|
|
2270
|
+
);
|
|
1367
2271
|
console.log(" Waiting for commands from Pulso agent...");
|
|
1368
2272
|
ws.send(JSON.stringify({ type: "extension_ready" }));
|
|
1369
2273
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
@@ -1384,10 +2288,16 @@ function connect() {
|
|
|
1384
2288
|
return;
|
|
1385
2289
|
}
|
|
1386
2290
|
if (msg.id && msg.command) {
|
|
1387
|
-
console.log(
|
|
1388
|
-
|
|
2291
|
+
console.log(
|
|
2292
|
+
`
|
|
2293
|
+
\u26A1 Command: ${msg.command}`,
|
|
2294
|
+
msg.params ? JSON.stringify(msg.params).slice(0, 200) : ""
|
|
2295
|
+
);
|
|
1389
2296
|
const result = await handleCommand(msg.command, msg.params ?? {});
|
|
1390
|
-
console.log(
|
|
2297
|
+
console.log(
|
|
2298
|
+
` \u2192 ${result.success ? "\u2705" : "\u274C"}`,
|
|
2299
|
+
result.success ? JSON.stringify(result.data).slice(0, 200) : result.error
|
|
2300
|
+
);
|
|
1391
2301
|
ws.send(JSON.stringify({ id: msg.id, result }));
|
|
1392
2302
|
return;
|
|
1393
2303
|
}
|
|
@@ -1405,9 +2315,15 @@ function connect() {
|
|
|
1405
2315
|
heartbeatTimer = null;
|
|
1406
2316
|
}
|
|
1407
2317
|
if (reasonStr === "New connection from same user") {
|
|
1408
|
-
console.log(
|
|
1409
|
-
|
|
1410
|
-
|
|
2318
|
+
console.log(
|
|
2319
|
+
"\n\u26A0\uFE0F Another Pulso Companion instance is already connected."
|
|
2320
|
+
);
|
|
2321
|
+
console.log(
|
|
2322
|
+
" This instance will exit. Only one companion per account is supported."
|
|
2323
|
+
);
|
|
2324
|
+
console.log(
|
|
2325
|
+
" If this is unexpected, close other terminals running Pulso Companion.\n"
|
|
2326
|
+
);
|
|
1411
2327
|
process.exit(0);
|
|
1412
2328
|
return;
|
|
1413
2329
|
}
|
|
@@ -1420,29 +2336,209 @@ function connect() {
|
|
|
1420
2336
|
function scheduleReconnect() {
|
|
1421
2337
|
if (reconnectTimer) return;
|
|
1422
2338
|
reconnectAttempts++;
|
|
1423
|
-
const delay = Math.min(
|
|
1424
|
-
|
|
2339
|
+
const delay = Math.min(
|
|
2340
|
+
RECONNECT_DELAY * Math.pow(2, reconnectAttempts - 1),
|
|
2341
|
+
6e4
|
|
2342
|
+
);
|
|
2343
|
+
console.log(
|
|
2344
|
+
` Reconnecting in ${(delay / 1e3).toFixed(0)}s... (attempt ${reconnectAttempts})`
|
|
2345
|
+
);
|
|
1425
2346
|
reconnectTimer = setTimeout(() => {
|
|
1426
2347
|
reconnectTimer = null;
|
|
1427
2348
|
connect();
|
|
1428
2349
|
}, delay);
|
|
1429
2350
|
}
|
|
2351
|
+
var wakeWordActive = false;
|
|
2352
|
+
async function startWakeWordDetection() {
|
|
2353
|
+
if (!WAKE_WORD_ENABLED) return;
|
|
2354
|
+
if (!PICOVOICE_ACCESS_KEY) {
|
|
2355
|
+
console.log(" \u26A0\uFE0F Wake word enabled but no Picovoice key provided.");
|
|
2356
|
+
console.log(" Get a free key at https://console.picovoice.ai/");
|
|
2357
|
+
console.log(
|
|
2358
|
+
" Use: --picovoice-key <key> or PICOVOICE_ACCESS_KEY env var\n"
|
|
2359
|
+
);
|
|
2360
|
+
return;
|
|
2361
|
+
}
|
|
2362
|
+
try {
|
|
2363
|
+
const { Porcupine, BuiltinKeyword } = await import("@picovoice/porcupine-node");
|
|
2364
|
+
const { PvRecorder } = await import("@picovoice/pvrecorder-node");
|
|
2365
|
+
const customKeywordPath = join(HOME, ".pulso-wake-word.ppn");
|
|
2366
|
+
const useCustom = existsSync(customKeywordPath);
|
|
2367
|
+
let porcupine;
|
|
2368
|
+
if (useCustom) {
|
|
2369
|
+
porcupine = new Porcupine(
|
|
2370
|
+
PICOVOICE_ACCESS_KEY,
|
|
2371
|
+
[customKeywordPath],
|
|
2372
|
+
[0.5]
|
|
2373
|
+
// sensitivity
|
|
2374
|
+
);
|
|
2375
|
+
console.log(
|
|
2376
|
+
" \u{1F3A4} Wake word: custom model loaded from ~/.pulso-wake-word.ppn"
|
|
2377
|
+
);
|
|
2378
|
+
} else {
|
|
2379
|
+
porcupine = new Porcupine(
|
|
2380
|
+
PICOVOICE_ACCESS_KEY,
|
|
2381
|
+
[BuiltinKeyword.HEY_GOOGLE],
|
|
2382
|
+
// Placeholder — replace with custom "Hey Pulso"
|
|
2383
|
+
[0.5]
|
|
2384
|
+
);
|
|
2385
|
+
console.log(
|
|
2386
|
+
' \u{1F3A4} Wake word: using "Hey Google" as placeholder (create custom "Hey Pulso" at console.picovoice.ai)'
|
|
2387
|
+
);
|
|
2388
|
+
}
|
|
2389
|
+
const frameLength = porcupine.frameLength;
|
|
2390
|
+
const sampleRate = porcupine.sampleRate;
|
|
2391
|
+
const devices = PvRecorder.getAvailableDevices();
|
|
2392
|
+
console.log(` \u{1F3A4} Audio devices: ${devices.length} found (using default)`);
|
|
2393
|
+
const recorder = new PvRecorder(frameLength, 0);
|
|
2394
|
+
recorder.start();
|
|
2395
|
+
wakeWordActive = true;
|
|
2396
|
+
console.log(" \u{1F3A4} Wake word detection ACTIVE \u2014 listening...\n");
|
|
2397
|
+
const listen = async () => {
|
|
2398
|
+
while (wakeWordActive) {
|
|
2399
|
+
try {
|
|
2400
|
+
const pcm = await recorder.read();
|
|
2401
|
+
const keywordIndex = porcupine.process(pcm);
|
|
2402
|
+
if (keywordIndex >= 0) {
|
|
2403
|
+
console.log("\n \u{1F5E3}\uFE0F Wake word detected!");
|
|
2404
|
+
exec("afplay /System/Library/Sounds/Tink.aiff");
|
|
2405
|
+
const audioChunks = [];
|
|
2406
|
+
let silenceFrames = 0;
|
|
2407
|
+
const MAX_SILENCE_FRAMES = Math.ceil(
|
|
2408
|
+
1.5 * sampleRate / frameLength
|
|
2409
|
+
);
|
|
2410
|
+
const MAX_RECORD_FRAMES = Math.ceil(
|
|
2411
|
+
10 * sampleRate / frameLength
|
|
2412
|
+
);
|
|
2413
|
+
let totalFrames = 0;
|
|
2414
|
+
console.log(" \u{1F3A4} Listening for command...");
|
|
2415
|
+
while (totalFrames < MAX_RECORD_FRAMES) {
|
|
2416
|
+
const frame = await recorder.read();
|
|
2417
|
+
audioChunks.push(new Int16Array(frame));
|
|
2418
|
+
totalFrames++;
|
|
2419
|
+
let sum = 0;
|
|
2420
|
+
for (let i = 0; i < frame.length; i++) {
|
|
2421
|
+
sum += frame[i] * frame[i];
|
|
2422
|
+
}
|
|
2423
|
+
const rms = Math.sqrt(sum / frame.length);
|
|
2424
|
+
if (rms < 200) {
|
|
2425
|
+
silenceFrames++;
|
|
2426
|
+
if (silenceFrames >= MAX_SILENCE_FRAMES && totalFrames > 5) {
|
|
2427
|
+
break;
|
|
2428
|
+
}
|
|
2429
|
+
} else {
|
|
2430
|
+
silenceFrames = 0;
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
console.log(
|
|
2434
|
+
` \u{1F3A4} Captured ${(totalFrames * frameLength / sampleRate).toFixed(1)}s of audio`
|
|
2435
|
+
);
|
|
2436
|
+
const totalSamples = audioChunks.reduce((s, c) => s + c.length, 0);
|
|
2437
|
+
const wavBuffer = createWavBuffer(
|
|
2438
|
+
audioChunks,
|
|
2439
|
+
sampleRate,
|
|
2440
|
+
totalSamples
|
|
2441
|
+
);
|
|
2442
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
2443
|
+
const base64Audio = Buffer.from(wavBuffer).toString("base64");
|
|
2444
|
+
ws.send(
|
|
2445
|
+
JSON.stringify({
|
|
2446
|
+
type: "wake_word_audio",
|
|
2447
|
+
audio: base64Audio,
|
|
2448
|
+
format: "wav",
|
|
2449
|
+
sampleRate,
|
|
2450
|
+
durationMs: Math.round(
|
|
2451
|
+
totalFrames * frameLength / sampleRate * 1e3
|
|
2452
|
+
)
|
|
2453
|
+
})
|
|
2454
|
+
);
|
|
2455
|
+
console.log(" \u{1F4E4} Audio sent to server for processing");
|
|
2456
|
+
exec("afplay /System/Library/Sounds/Pop.aiff");
|
|
2457
|
+
} else {
|
|
2458
|
+
console.log(" \u26A0\uFE0F Not connected to server \u2014 audio discarded");
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
} catch (err) {
|
|
2462
|
+
if (wakeWordActive) {
|
|
2463
|
+
console.error(" \u274C Wake word error:", err.message);
|
|
2464
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
recorder.stop();
|
|
2469
|
+
recorder.release();
|
|
2470
|
+
porcupine.release();
|
|
2471
|
+
};
|
|
2472
|
+
listen().catch((err) => {
|
|
2473
|
+
console.error(" \u274C Wake word listener crashed:", err.message);
|
|
2474
|
+
wakeWordActive = false;
|
|
2475
|
+
});
|
|
2476
|
+
} catch (err) {
|
|
2477
|
+
const msg = err.message;
|
|
2478
|
+
if (msg.includes("Cannot find module") || msg.includes("MODULE_NOT_FOUND")) {
|
|
2479
|
+
console.log(" \u26A0\uFE0F Wake word packages not installed. Run:");
|
|
2480
|
+
console.log(
|
|
2481
|
+
" npm install @picovoice/porcupine-node @picovoice/pvrecorder-node\n"
|
|
2482
|
+
);
|
|
2483
|
+
} else {
|
|
2484
|
+
console.error(" \u274C Wake word init failed:", msg);
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
function createWavBuffer(chunks, sampleRate, totalSamples) {
|
|
2489
|
+
const bytesPerSample = 2;
|
|
2490
|
+
const numChannels = 1;
|
|
2491
|
+
const dataSize = totalSamples * bytesPerSample;
|
|
2492
|
+
const buffer = new ArrayBuffer(44 + dataSize);
|
|
2493
|
+
const view = new DataView(buffer);
|
|
2494
|
+
writeString(view, 0, "RIFF");
|
|
2495
|
+
view.setUint32(4, 36 + dataSize, true);
|
|
2496
|
+
writeString(view, 8, "WAVE");
|
|
2497
|
+
writeString(view, 12, "fmt ");
|
|
2498
|
+
view.setUint32(16, 16, true);
|
|
2499
|
+
view.setUint16(20, 1, true);
|
|
2500
|
+
view.setUint16(22, numChannels, true);
|
|
2501
|
+
view.setUint32(24, sampleRate, true);
|
|
2502
|
+
view.setUint32(28, sampleRate * numChannels * bytesPerSample, true);
|
|
2503
|
+
view.setUint16(32, numChannels * bytesPerSample, true);
|
|
2504
|
+
view.setUint16(34, bytesPerSample * 8, true);
|
|
2505
|
+
writeString(view, 36, "data");
|
|
2506
|
+
view.setUint32(40, dataSize, true);
|
|
2507
|
+
let offset = 44;
|
|
2508
|
+
for (const chunk of chunks) {
|
|
2509
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
2510
|
+
view.setInt16(offset, chunk[i], true);
|
|
2511
|
+
offset += 2;
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
return buffer;
|
|
2515
|
+
}
|
|
2516
|
+
function writeString(view, offset, str) {
|
|
2517
|
+
for (let i = 0; i < str.length; i++) {
|
|
2518
|
+
view.setUint8(offset + i, str.charCodeAt(i));
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
1430
2521
|
console.log("");
|
|
1431
2522
|
console.log(" \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557");
|
|
1432
|
-
console.log(" \u2551 \u{1FAC0} Pulso Mac Companion v0.2.
|
|
2523
|
+
console.log(" \u2551 \u{1FAC0} Pulso Mac Companion v0.2.3 \u2551");
|
|
1433
2524
|
console.log(" \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D");
|
|
1434
2525
|
console.log("");
|
|
1435
2526
|
setupPermissions().then(() => {
|
|
1436
2527
|
connect();
|
|
2528
|
+
if (WAKE_WORD_ENABLED) {
|
|
2529
|
+
startWakeWordDetection();
|
|
2530
|
+
}
|
|
1437
2531
|
}).catch(() => {
|
|
1438
2532
|
connect();
|
|
1439
2533
|
});
|
|
1440
2534
|
process.on("SIGINT", () => {
|
|
1441
2535
|
console.log("\n\u{1F44B} Shutting down Pulso Companion...");
|
|
2536
|
+
wakeWordActive = false;
|
|
1442
2537
|
ws?.close(1e3, "User shutdown");
|
|
1443
2538
|
process.exit(0);
|
|
1444
2539
|
});
|
|
1445
2540
|
process.on("SIGTERM", () => {
|
|
2541
|
+
wakeWordActive = false;
|
|
1446
2542
|
ws?.close(1e3, "Process terminated");
|
|
1447
2543
|
process.exit(0);
|
|
1448
2544
|
});
|