@pulso/companion 0.2.1 → 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 +1278 -157
- 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,7 +248,27 @@ 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);
|
|
269
|
+
if (trackIds.length === 0) {
|
|
270
|
+
trackIds = await searchDDGForSpotifyTracks(query);
|
|
271
|
+
}
|
|
198
272
|
if (trackIds.length === 0) {
|
|
199
273
|
trackIds = await searchStartpageForSpotifyTracks(query);
|
|
200
274
|
}
|
|
@@ -210,6 +284,72 @@ async function spotifySearch(query) {
|
|
|
210
284
|
});
|
|
211
285
|
return result;
|
|
212
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
|
+
}
|
|
213
353
|
async function searchBraveForSpotifyTracks(query) {
|
|
214
354
|
try {
|
|
215
355
|
const searchQuery = `spotify track ${query} site:open.spotify.com`;
|
|
@@ -224,7 +364,33 @@ async function searchBraveForSpotifyTracks(query) {
|
|
|
224
364
|
if (!res.ok) return [];
|
|
225
365
|
const html = await res.text();
|
|
226
366
|
const trackIds = /* @__PURE__ */ new Set();
|
|
227
|
-
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
|
+
)) {
|
|
370
|
+
trackIds.add(m[1]);
|
|
371
|
+
}
|
|
372
|
+
return [...trackIds];
|
|
373
|
+
} catch {
|
|
374
|
+
return [];
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async function searchDDGForSpotifyTracks(query) {
|
|
378
|
+
try {
|
|
379
|
+
const searchQuery = `spotify track ${query} site:open.spotify.com`;
|
|
380
|
+
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(searchQuery)}`;
|
|
381
|
+
const controller = new AbortController();
|
|
382
|
+
const timeout = setTimeout(() => controller.abort(), 8e3);
|
|
383
|
+
const res = await fetch(url, {
|
|
384
|
+
headers: { "User-Agent": UA, Accept: "text/html" },
|
|
385
|
+
signal: controller.signal
|
|
386
|
+
});
|
|
387
|
+
clearTimeout(timeout);
|
|
388
|
+
if (!res.ok) return [];
|
|
389
|
+
const html = await res.text();
|
|
390
|
+
const trackIds = /* @__PURE__ */ new Set();
|
|
391
|
+
for (const m of html.matchAll(
|
|
392
|
+
/open\.spotify\.com(?:\/intl-[a-z]+)?\/track\/([a-zA-Z0-9]{22})/g
|
|
393
|
+
)) {
|
|
228
394
|
trackIds.add(m[1]);
|
|
229
395
|
}
|
|
230
396
|
return [...trackIds];
|
|
@@ -250,7 +416,9 @@ async function searchStartpageForSpotifyTracks(query) {
|
|
|
250
416
|
if (!res.ok) return [];
|
|
251
417
|
const html = await res.text();
|
|
252
418
|
const trackIds = /* @__PURE__ */ new Set();
|
|
253
|
-
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
|
+
)) {
|
|
254
422
|
trackIds.add(m[1]);
|
|
255
423
|
}
|
|
256
424
|
return [...trackIds];
|
|
@@ -269,7 +437,9 @@ async function getTrackMetadata(trackId) {
|
|
|
269
437
|
clearTimeout(timeout);
|
|
270
438
|
if (!res.ok) return null;
|
|
271
439
|
const html = await res.text();
|
|
272
|
-
const scriptMatch = html.match(
|
|
440
|
+
const scriptMatch = html.match(
|
|
441
|
+
/<script[^>]*>(\{"props":\{"pageProps".*?\})<\/script>/s
|
|
442
|
+
);
|
|
273
443
|
if (!scriptMatch) return null;
|
|
274
444
|
const data = JSON.parse(scriptMatch[1]);
|
|
275
445
|
const entity = data?.props?.pageProps?.state?.data?.entity;
|
|
@@ -321,7 +491,8 @@ async function handleCommand(command, params) {
|
|
|
321
491
|
case "sys_notification": {
|
|
322
492
|
const title = params.title;
|
|
323
493
|
const message = params.message;
|
|
324
|
-
if (!title || !message)
|
|
494
|
+
if (!title || !message)
|
|
495
|
+
return { success: false, error: "Missing title or message" };
|
|
325
496
|
await runAppleScript(
|
|
326
497
|
`display notification "${message.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"`
|
|
327
498
|
);
|
|
@@ -350,26 +521,48 @@ async function handleCommand(command, params) {
|
|
|
350
521
|
await runAppleScript('tell application "Spotify" to next track');
|
|
351
522
|
return { success: true, data: { action: "next" } };
|
|
352
523
|
case "previous":
|
|
353
|
-
await runAppleScript(
|
|
524
|
+
await runAppleScript(
|
|
525
|
+
'tell application "Spotify" to previous track'
|
|
526
|
+
);
|
|
354
527
|
return { success: true, data: { action: "previous" } };
|
|
355
528
|
case "now_playing": {
|
|
356
|
-
const name = await runAppleScript(
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
|
|
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
|
+
};
|
|
361
545
|
}
|
|
362
546
|
case "search_play": {
|
|
363
547
|
const query = params.query;
|
|
364
|
-
if (!query)
|
|
548
|
+
if (!query)
|
|
549
|
+
return { success: false, error: "Missing search query" };
|
|
365
550
|
const result = await spotifySearch(query);
|
|
366
551
|
if (result) {
|
|
367
|
-
await runAppleScript(
|
|
552
|
+
await runAppleScript(
|
|
553
|
+
`tell application "Spotify" to play track "${result.uri}"`
|
|
554
|
+
);
|
|
368
555
|
await new Promise((r) => setTimeout(r, 1500));
|
|
369
556
|
try {
|
|
370
|
-
const track = await runAppleScript(
|
|
371
|
-
|
|
372
|
-
|
|
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
|
+
);
|
|
373
566
|
return {
|
|
374
567
|
success: true,
|
|
375
568
|
data: {
|
|
@@ -380,10 +573,19 @@ async function handleCommand(command, params) {
|
|
|
380
573
|
}
|
|
381
574
|
};
|
|
382
575
|
} catch {
|
|
383
|
-
return {
|
|
576
|
+
return {
|
|
577
|
+
success: true,
|
|
578
|
+
data: {
|
|
579
|
+
searched: query,
|
|
580
|
+
resolved: `${result.name} - ${result.artist}`,
|
|
581
|
+
note: "Playing track"
|
|
582
|
+
}
|
|
583
|
+
};
|
|
384
584
|
}
|
|
385
585
|
}
|
|
386
|
-
await runShell(
|
|
586
|
+
await runShell(
|
|
587
|
+
`open "spotify:search:${encodeURIComponent(query)}"`
|
|
588
|
+
);
|
|
387
589
|
return {
|
|
388
590
|
success: true,
|
|
389
591
|
data: {
|
|
@@ -395,34 +597,61 @@ async function handleCommand(command, params) {
|
|
|
395
597
|
}
|
|
396
598
|
case "volume": {
|
|
397
599
|
const level = params.level;
|
|
398
|
-
if (level === void 0 || level < 0 || level > 100)
|
|
399
|
-
|
|
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
|
+
);
|
|
400
605
|
return { success: true, data: { volume: level } };
|
|
401
606
|
}
|
|
402
607
|
case "shuffle": {
|
|
403
608
|
const enabled = params.enabled;
|
|
404
|
-
await runAppleScript(
|
|
609
|
+
await runAppleScript(
|
|
610
|
+
`tell application "Spotify" to set shuffling to ${enabled ? "true" : "false"}`
|
|
611
|
+
);
|
|
405
612
|
return { success: true, data: { shuffling: enabled } };
|
|
406
613
|
}
|
|
407
614
|
case "repeat": {
|
|
408
615
|
const mode = params.mode;
|
|
409
|
-
if (!mode)
|
|
410
|
-
|
|
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
|
+
};
|
|
411
626
|
const val = repeatMap[mode];
|
|
412
|
-
if (!val)
|
|
413
|
-
|
|
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
|
+
);
|
|
414
635
|
return { success: true, data: { repeating: mode } };
|
|
415
636
|
}
|
|
416
637
|
default:
|
|
417
|
-
return {
|
|
638
|
+
return {
|
|
639
|
+
success: false,
|
|
640
|
+
error: `Unknown Spotify action: ${action}`
|
|
641
|
+
};
|
|
418
642
|
}
|
|
419
643
|
}
|
|
420
644
|
case "sys_file_read": {
|
|
421
645
|
const path = params.path;
|
|
422
646
|
if (!path) return { success: false, error: "Missing file path" };
|
|
423
647
|
const fullPath = safePath(path);
|
|
424
|
-
if (!fullPath)
|
|
425
|
-
|
|
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}` };
|
|
426
655
|
const content = readFileSync(fullPath, "utf-8");
|
|
427
656
|
return {
|
|
428
657
|
success: true,
|
|
@@ -437,9 +666,14 @@ async function handleCommand(command, params) {
|
|
|
437
666
|
case "sys_file_write": {
|
|
438
667
|
const path = params.path;
|
|
439
668
|
const content = params.content;
|
|
440
|
-
if (!path || !content)
|
|
669
|
+
if (!path || !content)
|
|
670
|
+
return { success: false, error: "Missing path or content" };
|
|
441
671
|
const fullPath = safePath(path);
|
|
442
|
-
if (!fullPath)
|
|
672
|
+
if (!fullPath)
|
|
673
|
+
return {
|
|
674
|
+
success: false,
|
|
675
|
+
error: `Access denied. Only files in ${SAFE_DIRS.join(", ")} are allowed.`
|
|
676
|
+
};
|
|
443
677
|
writeFileSync(fullPath, content, "utf-8");
|
|
444
678
|
return { success: true, data: { path, written: content.length } };
|
|
445
679
|
}
|
|
@@ -448,20 +682,33 @@ async function handleCommand(command, params) {
|
|
|
448
682
|
const pngPath = `/tmp/pulso-ss-${ts}.png`;
|
|
449
683
|
const jpgPath = `/tmp/pulso-ss-${ts}.jpg`;
|
|
450
684
|
await runShell(`screencapture -x ${pngPath}`, 15e3);
|
|
451
|
-
if (!existsSync(pngPath))
|
|
685
|
+
if (!existsSync(pngPath))
|
|
686
|
+
return { success: false, error: "Screenshot failed" };
|
|
452
687
|
try {
|
|
453
|
-
await runShell(
|
|
688
|
+
await runShell(
|
|
689
|
+
`sips --resampleWidth 1280 --setProperty format jpeg --setProperty formatOptions 60 ${pngPath} --out ${jpgPath}`,
|
|
690
|
+
1e4
|
|
691
|
+
);
|
|
454
692
|
} catch {
|
|
455
693
|
const buf2 = readFileSync(pngPath);
|
|
456
694
|
exec(`rm -f ${pngPath}`);
|
|
457
|
-
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
|
+
};
|
|
458
703
|
}
|
|
459
704
|
const buf = readFileSync(jpgPath);
|
|
460
705
|
const base64 = buf.toString("base64");
|
|
461
706
|
exec(`rm -f ${pngPath} ${jpgPath}`);
|
|
462
707
|
let screenSize = "unknown";
|
|
463
708
|
try {
|
|
464
|
-
screenSize = await runShell(
|
|
709
|
+
screenSize = await runShell(
|
|
710
|
+
`system_profiler SPDisplaysDataType 2>/dev/null | grep Resolution | head -1 | sed 's/.*: //'`
|
|
711
|
+
);
|
|
465
712
|
} catch {
|
|
466
713
|
}
|
|
467
714
|
return {
|
|
@@ -480,7 +727,8 @@ async function handleCommand(command, params) {
|
|
|
480
727
|
const x = Number(params.x);
|
|
481
728
|
const y = Number(params.y);
|
|
482
729
|
const button = params.button || "left";
|
|
483
|
-
if (isNaN(x) || isNaN(y))
|
|
730
|
+
if (isNaN(x) || isNaN(y))
|
|
731
|
+
return { success: false, error: "Missing x, y coordinates" };
|
|
484
732
|
const mouseType = button === "right" ? "rightMouseDown" : "leftMouseDown";
|
|
485
733
|
const mouseTypeUp = button === "right" ? "rightMouseUp" : "leftMouseUp";
|
|
486
734
|
const mouseButton = button === "right" ? ".right" : ".left";
|
|
@@ -499,7 +747,8 @@ print("clicked")`;
|
|
|
499
747
|
case "sys_mouse_double_click": {
|
|
500
748
|
const x = Number(params.x);
|
|
501
749
|
const y = Number(params.y);
|
|
502
|
-
if (isNaN(x) || isNaN(y))
|
|
750
|
+
if (isNaN(x) || isNaN(y))
|
|
751
|
+
return { success: false, error: "Missing x, y coordinates" };
|
|
503
752
|
const swift = `
|
|
504
753
|
import Cocoa
|
|
505
754
|
let p = CGPoint(x: ${x}, y: ${y})
|
|
@@ -527,7 +776,8 @@ print("double-clicked")`;
|
|
|
527
776
|
const y = Number(params.y) || 0;
|
|
528
777
|
const scrollY = Number(params.scrollY) || 0;
|
|
529
778
|
const scrollX = Number(params.scrollX) || 0;
|
|
530
|
-
if (!scrollY && !scrollX)
|
|
779
|
+
if (!scrollY && !scrollX)
|
|
780
|
+
return { success: false, error: "Missing scrollY or scrollX" };
|
|
531
781
|
const swift = `
|
|
532
782
|
import Cocoa
|
|
533
783
|
let p = CGPoint(x: ${x}, y: ${y})
|
|
@@ -538,12 +788,17 @@ let scroll = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2,
|
|
|
538
788
|
scroll.post(tap: .cghidEventTap)
|
|
539
789
|
print("scrolled")`;
|
|
540
790
|
await runSwift(swift);
|
|
541
|
-
return {
|
|
791
|
+
return {
|
|
792
|
+
success: true,
|
|
793
|
+
data: { scrolled: { x, y, scrollY, scrollX } }
|
|
794
|
+
};
|
|
542
795
|
}
|
|
543
796
|
case "sys_keyboard_type": {
|
|
544
797
|
const text = params.text;
|
|
545
798
|
if (!text) return { success: false, error: "Missing text" };
|
|
546
|
-
await runAppleScript(
|
|
799
|
+
await runAppleScript(
|
|
800
|
+
`tell application "System Events" to keystroke "${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
|
|
801
|
+
);
|
|
547
802
|
return { success: true, data: { typed: text.slice(0, 100) } };
|
|
548
803
|
}
|
|
549
804
|
case "sys_key_press": {
|
|
@@ -594,19 +849,27 @@ print("scrolled")`;
|
|
|
594
849
|
const keyCode = keyCodeMap[key.toLowerCase()];
|
|
595
850
|
if (keyCode !== void 0) {
|
|
596
851
|
const using = modStr.length > 0 ? ` using {${modStr.join(", ")}}` : "";
|
|
597
|
-
await runAppleScript(
|
|
852
|
+
await runAppleScript(
|
|
853
|
+
`tell application "System Events" to key code ${keyCode}${using}`
|
|
854
|
+
);
|
|
598
855
|
} else if (key.length === 1) {
|
|
599
856
|
const using = modStr.length > 0 ? ` using {${modStr.join(", ")}}` : "";
|
|
600
|
-
await runAppleScript(
|
|
857
|
+
await runAppleScript(
|
|
858
|
+
`tell application "System Events" to keystroke "${key}"${using}`
|
|
859
|
+
);
|
|
601
860
|
} else {
|
|
602
|
-
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
|
+
};
|
|
603
865
|
}
|
|
604
866
|
return { success: true, data: { pressed: key, modifiers } };
|
|
605
867
|
}
|
|
606
868
|
case "sys_mouse_move": {
|
|
607
869
|
const x = Number(params.x);
|
|
608
870
|
const y = Number(params.y);
|
|
609
|
-
if (isNaN(x) || isNaN(y))
|
|
871
|
+
if (isNaN(x) || isNaN(y))
|
|
872
|
+
return { success: false, error: "Missing x, y coordinates" };
|
|
610
873
|
const swift = `
|
|
611
874
|
import Cocoa
|
|
612
875
|
let p = CGPoint(x: ${x}, y: ${y})
|
|
@@ -621,7 +884,8 @@ print("moved")`;
|
|
|
621
884
|
const fromY = Number(params.fromY);
|
|
622
885
|
const toX = Number(params.toX);
|
|
623
886
|
const toY = Number(params.toY);
|
|
624
|
-
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" };
|
|
625
889
|
const swift = `
|
|
626
890
|
import Cocoa
|
|
627
891
|
let from = CGPoint(x: ${fromX}, y: ${fromY})
|
|
@@ -641,7 +905,12 @@ let u = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosit
|
|
|
641
905
|
u.post(tap: .cghidEventTap)
|
|
642
906
|
print("dragged")`;
|
|
643
907
|
await runSwift(swift);
|
|
644
|
-
return {
|
|
908
|
+
return {
|
|
909
|
+
success: true,
|
|
910
|
+
data: {
|
|
911
|
+
dragged: { from: { x: fromX, y: fromY }, to: { x: toX, y: toY } }
|
|
912
|
+
}
|
|
913
|
+
};
|
|
645
914
|
}
|
|
646
915
|
case "sys_get_cursor_position": {
|
|
647
916
|
const swift = `
|
|
@@ -659,7 +928,13 @@ print("\\(x),\\(y)")`;
|
|
|
659
928
|
}
|
|
660
929
|
// ── Browser Automation ─────────────────────────────────
|
|
661
930
|
case "sys_browser_list_tabs": {
|
|
662
|
-
const browsers = [
|
|
931
|
+
const browsers = [
|
|
932
|
+
"Google Chrome",
|
|
933
|
+
"Safari",
|
|
934
|
+
"Arc",
|
|
935
|
+
"Firefox",
|
|
936
|
+
"Microsoft Edge"
|
|
937
|
+
];
|
|
663
938
|
const allTabs = [];
|
|
664
939
|
for (const browser of browsers) {
|
|
665
940
|
try {
|
|
@@ -683,7 +958,12 @@ print("\\(x),\\(y)")`;
|
|
|
683
958
|
const tabStr = rest.join("~~~");
|
|
684
959
|
const pairs = tabStr.split("|||").filter(Boolean);
|
|
685
960
|
for (let i = 0; i < pairs.length - 1; i += 2) {
|
|
686
|
-
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
|
+
});
|
|
687
967
|
}
|
|
688
968
|
} else {
|
|
689
969
|
const tabData = await runAppleScript(`
|
|
@@ -701,13 +981,21 @@ print("\\(x),\\(y)")`;
|
|
|
701
981
|
const tabStr = rest.join("~~~");
|
|
702
982
|
const pairs = tabStr.split("|||").filter(Boolean);
|
|
703
983
|
for (let i = 0; i < pairs.length - 1; i += 2) {
|
|
704
|
-
allTabs.push({
|
|
984
|
+
allTabs.push({
|
|
985
|
+
browser,
|
|
986
|
+
title: pairs[i],
|
|
987
|
+
url: pairs[i + 1],
|
|
988
|
+
active: pairs[i + 1] === activeURL.trim()
|
|
989
|
+
});
|
|
705
990
|
}
|
|
706
991
|
}
|
|
707
992
|
} catch {
|
|
708
993
|
}
|
|
709
994
|
}
|
|
710
|
-
return {
|
|
995
|
+
return {
|
|
996
|
+
success: true,
|
|
997
|
+
data: { tabs: allTabs, count: allTabs.length }
|
|
998
|
+
};
|
|
711
999
|
}
|
|
712
1000
|
case "sys_browser_navigate": {
|
|
713
1001
|
const url = params.url;
|
|
@@ -735,7 +1023,10 @@ print("\\(x),\\(y)")`;
|
|
|
735
1023
|
}
|
|
736
1024
|
return { success: true, data: { navigated: url, browser } };
|
|
737
1025
|
} catch (err) {
|
|
738
|
-
return {
|
|
1026
|
+
return {
|
|
1027
|
+
success: false,
|
|
1028
|
+
error: `Failed to navigate: ${err.message}`
|
|
1029
|
+
};
|
|
739
1030
|
}
|
|
740
1031
|
}
|
|
741
1032
|
case "sys_browser_new_tab": {
|
|
@@ -758,7 +1049,10 @@ print("\\(x),\\(y)")`;
|
|
|
758
1049
|
}
|
|
759
1050
|
return { success: true, data: { opened: url, browser } };
|
|
760
1051
|
} catch (err) {
|
|
761
|
-
return {
|
|
1052
|
+
return {
|
|
1053
|
+
success: false,
|
|
1054
|
+
error: `Failed to open tab: ${err.message}`
|
|
1055
|
+
};
|
|
762
1056
|
}
|
|
763
1057
|
}
|
|
764
1058
|
case "sys_browser_read_page": {
|
|
@@ -784,32 +1078,53 @@ print("\\(x),\\(y)")`;
|
|
|
784
1078
|
}
|
|
785
1079
|
} catch {
|
|
786
1080
|
try {
|
|
787
|
-
const savedClipboard = await runShell(
|
|
788
|
-
|
|
1081
|
+
const savedClipboard = await runShell(
|
|
1082
|
+
"pbpaste 2>/dev/null || true"
|
|
1083
|
+
);
|
|
1084
|
+
await runAppleScript(
|
|
1085
|
+
`tell application "${browser.replace(/"/g, '\\"')}" to activate`
|
|
1086
|
+
);
|
|
789
1087
|
await new Promise((r) => setTimeout(r, 300));
|
|
790
|
-
await runAppleScript(
|
|
1088
|
+
await runAppleScript(
|
|
1089
|
+
'tell application "System Events" to keystroke "a" using command down'
|
|
1090
|
+
);
|
|
791
1091
|
await new Promise((r) => setTimeout(r, 200));
|
|
792
|
-
await runAppleScript(
|
|
1092
|
+
await runAppleScript(
|
|
1093
|
+
'tell application "System Events" to keystroke "c" using command down'
|
|
1094
|
+
);
|
|
793
1095
|
await new Promise((r) => setTimeout(r, 300));
|
|
794
1096
|
content = await runShell("pbpaste");
|
|
795
1097
|
method = "clipboard";
|
|
796
|
-
await runAppleScript(
|
|
1098
|
+
await runAppleScript(
|
|
1099
|
+
'tell application "System Events" to key code 53'
|
|
1100
|
+
);
|
|
797
1101
|
if (savedClipboard && savedClipboard !== content) {
|
|
798
1102
|
execSync(`echo ${JSON.stringify(savedClipboard)} | pbcopy`);
|
|
799
1103
|
}
|
|
800
1104
|
} catch (clipErr) {
|
|
801
|
-
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
|
+
};
|
|
802
1109
|
}
|
|
803
1110
|
}
|
|
804
1111
|
let pageUrl = "";
|
|
805
1112
|
let pageTitle = "";
|
|
806
1113
|
try {
|
|
807
1114
|
if (browser === "Safari") {
|
|
808
|
-
pageUrl = await runAppleScript(
|
|
809
|
-
|
|
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
|
+
);
|
|
810
1121
|
} else {
|
|
811
|
-
pageUrl = await runAppleScript(
|
|
812
|
-
|
|
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
|
+
);
|
|
813
1128
|
}
|
|
814
1129
|
} catch {
|
|
815
1130
|
}
|
|
@@ -832,13 +1147,20 @@ print("\\(x),\\(y)")`;
|
|
|
832
1147
|
try {
|
|
833
1148
|
let result;
|
|
834
1149
|
if (browser === "Safari") {
|
|
835
|
-
result = await runAppleScript(
|
|
1150
|
+
result = await runAppleScript(
|
|
1151
|
+
`tell application "Safari" to do JavaScript ${JSON.stringify(js)} in front document`
|
|
1152
|
+
);
|
|
836
1153
|
} else {
|
|
837
|
-
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
|
+
);
|
|
838
1157
|
}
|
|
839
1158
|
return { success: true, data: { result: result.slice(0, 5e3) } };
|
|
840
1159
|
} catch (err) {
|
|
841
|
-
return {
|
|
1160
|
+
return {
|
|
1161
|
+
success: false,
|
|
1162
|
+
error: `JS execution failed (enable 'Allow JavaScript from Apple Events' in browser): ${err.message}`
|
|
1163
|
+
};
|
|
842
1164
|
}
|
|
843
1165
|
}
|
|
844
1166
|
// ── Email ──────────────────────────────────────────────
|
|
@@ -847,11 +1169,20 @@ print("\\(x),\\(y)")`;
|
|
|
847
1169
|
const subject = params.subject;
|
|
848
1170
|
const body = params.body;
|
|
849
1171
|
const method = params.method || "mail";
|
|
850
|
-
if (!to || !subject || !body)
|
|
1172
|
+
if (!to || !subject || !body)
|
|
1173
|
+
return { success: false, error: "Missing to, subject, or body" };
|
|
851
1174
|
if (method === "gmail") {
|
|
852
1175
|
const gmailUrl = `https://mail.google.com/mail/u/0/?view=cm&fs=1&to=${encodeURIComponent(to)}&su=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
|
853
1176
|
await runShell(`open "${gmailUrl}"`);
|
|
854
|
-
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
|
+
};
|
|
855
1186
|
}
|
|
856
1187
|
try {
|
|
857
1188
|
await runAppleScript(`
|
|
@@ -862,11 +1193,22 @@ print("\\(x),\\(y)")`;
|
|
|
862
1193
|
end tell
|
|
863
1194
|
send newMessage
|
|
864
1195
|
end tell`);
|
|
865
|
-
return {
|
|
1196
|
+
return {
|
|
1197
|
+
success: true,
|
|
1198
|
+
data: { method: "mail", to, subject, sent: true }
|
|
1199
|
+
};
|
|
866
1200
|
} catch (err) {
|
|
867
1201
|
const gmailUrl = `https://mail.google.com/mail/u/0/?view=cm&fs=1&to=${encodeURIComponent(to)}&su=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
|
868
1202
|
await runShell(`open "${gmailUrl}"`);
|
|
869
|
-
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
|
+
};
|
|
870
1212
|
}
|
|
871
1213
|
}
|
|
872
1214
|
case "sys_run_shortcut": {
|
|
@@ -874,21 +1216,44 @@ print("\\(x),\\(y)")`;
|
|
|
874
1216
|
const input = params.input;
|
|
875
1217
|
if (!name) return { success: false, error: "Missing shortcut name" };
|
|
876
1218
|
const inputFlag = input ? `--input-type text --input "${input.replace(/"/g, '\\"')}"` : "";
|
|
877
|
-
const result = await runShell(
|
|
878
|
-
|
|
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
|
+
};
|
|
879
1227
|
}
|
|
880
1228
|
// ── Shell Execution ─────────────────────────────────────
|
|
881
1229
|
case "sys_shell": {
|
|
882
1230
|
const cmd = params.command;
|
|
883
1231
|
if (!cmd) return { success: false, error: "Missing command" };
|
|
884
|
-
const blocked = [
|
|
885
|
-
|
|
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" };
|
|
886
1241
|
const timeout = Number(params.timeout) || 15e3;
|
|
887
1242
|
try {
|
|
888
1243
|
const output = await runShell(cmd, timeout);
|
|
889
|
-
return {
|
|
1244
|
+
return {
|
|
1245
|
+
success: true,
|
|
1246
|
+
data: {
|
|
1247
|
+
command: cmd,
|
|
1248
|
+
output: output.slice(0, 1e4),
|
|
1249
|
+
truncated: output.length > 1e4
|
|
1250
|
+
}
|
|
1251
|
+
};
|
|
890
1252
|
} catch (err) {
|
|
891
|
-
return {
|
|
1253
|
+
return {
|
|
1254
|
+
success: false,
|
|
1255
|
+
error: `Shell error: ${err.message.slice(0, 2e3)}`
|
|
1256
|
+
};
|
|
892
1257
|
}
|
|
893
1258
|
}
|
|
894
1259
|
// ── Calendar ────────────────────────────────────────────
|
|
@@ -912,9 +1277,17 @@ print("\\(x),\\(y)")`;
|
|
|
912
1277
|
const raw = await runAppleScript(script);
|
|
913
1278
|
const events = raw.split("\n").filter(Boolean).map((line) => {
|
|
914
1279
|
const [cal, summary, start, end] = line.split(" | ");
|
|
915
|
-
return {
|
|
1280
|
+
return {
|
|
1281
|
+
calendar: cal?.trim(),
|
|
1282
|
+
summary: summary?.trim(),
|
|
1283
|
+
start: start?.trim(),
|
|
1284
|
+
end: end?.trim()
|
|
1285
|
+
};
|
|
916
1286
|
});
|
|
917
|
-
return {
|
|
1287
|
+
return {
|
|
1288
|
+
success: true,
|
|
1289
|
+
data: { events, count: events.length, daysAhead: days }
|
|
1290
|
+
};
|
|
918
1291
|
}
|
|
919
1292
|
case "sys_calendar_create": {
|
|
920
1293
|
const summary = params.summary || params.title;
|
|
@@ -922,7 +1295,8 @@ print("\\(x),\\(y)")`;
|
|
|
922
1295
|
const endStr = params.end;
|
|
923
1296
|
const calendar = params.calendar || "";
|
|
924
1297
|
const notes = params.notes || "";
|
|
925
|
-
if (!summary || !startStr)
|
|
1298
|
+
if (!summary || !startStr)
|
|
1299
|
+
return { success: false, error: "Missing summary or start time" };
|
|
926
1300
|
const calTarget = calendar ? `calendar "${calendar.replace(/"/g, '\\"')}"` : "default calendar";
|
|
927
1301
|
const endPart = endStr ? `set end date of newEvent to date "${endStr}"` : "";
|
|
928
1302
|
const notesPart = notes ? `set description of newEvent to "${notes.replace(/"/g, '\\"')}"` : "";
|
|
@@ -934,7 +1308,10 @@ print("\\(x),\\(y)")`;
|
|
|
934
1308
|
${notesPart}
|
|
935
1309
|
end tell
|
|
936
1310
|
end tell`);
|
|
937
|
-
return {
|
|
1311
|
+
return {
|
|
1312
|
+
success: true,
|
|
1313
|
+
data: { created: summary, start: startStr, end: endStr || "auto" }
|
|
1314
|
+
};
|
|
938
1315
|
}
|
|
939
1316
|
// ── Reminders ───────────────────────────────────────────
|
|
940
1317
|
case "sys_reminder_list": {
|
|
@@ -958,18 +1335,33 @@ print("\\(x),\\(y)")`;
|
|
|
958
1335
|
const raw = await runAppleScript(script);
|
|
959
1336
|
const reminders = raw.split("\n").filter(Boolean).map((line) => {
|
|
960
1337
|
const parts = line.split(" | ");
|
|
961
|
-
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() };
|
|
962
1343
|
});
|
|
963
|
-
return {
|
|
1344
|
+
return {
|
|
1345
|
+
success: true,
|
|
1346
|
+
data: { reminders, count: reminders.length }
|
|
1347
|
+
};
|
|
964
1348
|
} catch {
|
|
965
|
-
return {
|
|
1349
|
+
return {
|
|
1350
|
+
success: true,
|
|
1351
|
+
data: {
|
|
1352
|
+
reminders: [],
|
|
1353
|
+
count: 0,
|
|
1354
|
+
note: "No reminders or Reminders app not accessible"
|
|
1355
|
+
}
|
|
1356
|
+
};
|
|
966
1357
|
}
|
|
967
1358
|
}
|
|
968
1359
|
case "sys_reminder_create": {
|
|
969
1360
|
const reminderName = params.name;
|
|
970
1361
|
const dueDate = params.due;
|
|
971
1362
|
const listName2 = params.list || "Reminders";
|
|
972
|
-
if (!reminderName)
|
|
1363
|
+
if (!reminderName)
|
|
1364
|
+
return { success: false, error: "Missing reminder name" };
|
|
973
1365
|
const duePart = dueDate ? `, due date:date "${dueDate}"` : "";
|
|
974
1366
|
await runAppleScript(`
|
|
975
1367
|
tell application "Reminders"
|
|
@@ -977,20 +1369,34 @@ print("\\(x),\\(y)")`;
|
|
|
977
1369
|
make new reminder with properties {name:"${reminderName.replace(/"/g, '\\"')}"${duePart}}
|
|
978
1370
|
end tell
|
|
979
1371
|
end tell`);
|
|
980
|
-
return {
|
|
1372
|
+
return {
|
|
1373
|
+
success: true,
|
|
1374
|
+
data: {
|
|
1375
|
+
created: reminderName,
|
|
1376
|
+
due: dueDate || "none",
|
|
1377
|
+
list: listName2
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
981
1380
|
}
|
|
982
1381
|
// ── iMessage ────────────────────────────────────────────
|
|
983
1382
|
case "sys_imessage_send": {
|
|
984
1383
|
const to2 = params.to;
|
|
985
1384
|
const msg = params.message;
|
|
986
|
-
if (!to2 || !msg)
|
|
1385
|
+
if (!to2 || !msg)
|
|
1386
|
+
return {
|
|
1387
|
+
success: false,
|
|
1388
|
+
error: "Missing 'to' (phone/email) or 'message'"
|
|
1389
|
+
};
|
|
987
1390
|
await runAppleScript(`
|
|
988
1391
|
tell application "Messages"
|
|
989
1392
|
set targetService to 1st account whose service type = iMessage
|
|
990
1393
|
set targetBuddy to participant "${to2.replace(/"/g, '\\"')}" of targetService
|
|
991
1394
|
send "${msg.replace(/"/g, '\\"')}" to targetBuddy
|
|
992
1395
|
end tell`);
|
|
993
|
-
return {
|
|
1396
|
+
return {
|
|
1397
|
+
success: true,
|
|
1398
|
+
data: { sent: true, to: to2, message: msg.slice(0, 100) }
|
|
1399
|
+
};
|
|
994
1400
|
}
|
|
995
1401
|
// ── System Info ─────────────────────────────────────────
|
|
996
1402
|
case "sys_system_info": {
|
|
@@ -1001,7 +1407,9 @@ print("\\(x),\\(y)")`;
|
|
|
1001
1407
|
info.battery = "N/A";
|
|
1002
1408
|
}
|
|
1003
1409
|
try {
|
|
1004
|
-
info.wifi = (await runShell(
|
|
1410
|
+
info.wifi = (await runShell(
|
|
1411
|
+
"networksetup -getairportnetwork en0 2>/dev/null | cut -d: -f2"
|
|
1412
|
+
)).trim();
|
|
1005
1413
|
} catch {
|
|
1006
1414
|
info.wifi = "N/A";
|
|
1007
1415
|
}
|
|
@@ -1016,7 +1424,9 @@ print("\\(x),\\(y)")`;
|
|
|
1016
1424
|
info.ip_public = "N/A";
|
|
1017
1425
|
}
|
|
1018
1426
|
try {
|
|
1019
|
-
info.disk = (await runShell(
|
|
1427
|
+
info.disk = (await runShell(
|
|
1428
|
+
`df -h / | tail -1 | awk '{print $3 " used / " $2 " total (" $5 " used)"}'`
|
|
1429
|
+
)).trim();
|
|
1020
1430
|
} catch {
|
|
1021
1431
|
info.disk = "N/A";
|
|
1022
1432
|
}
|
|
@@ -1055,7 +1465,9 @@ print("\\(x),\\(y)")`;
|
|
|
1055
1465
|
await runAppleScript(`set volume output volume ${vol}`);
|
|
1056
1466
|
return { success: true, data: { volume: vol } };
|
|
1057
1467
|
}
|
|
1058
|
-
const raw2 = await runAppleScript(
|
|
1468
|
+
const raw2 = await runAppleScript(
|
|
1469
|
+
"output volume of (get volume settings)"
|
|
1470
|
+
);
|
|
1059
1471
|
return { success: true, data: { volume: Number(raw2) || 0 } };
|
|
1060
1472
|
}
|
|
1061
1473
|
// ── Brightness ──────────────────────────────────────────
|
|
@@ -1063,14 +1475,27 @@ print("\\(x),\\(y)")`;
|
|
|
1063
1475
|
const level2 = params.level;
|
|
1064
1476
|
if (level2 !== void 0) {
|
|
1065
1477
|
const br = Math.max(0, Math.min(1, Number(level2)));
|
|
1066
|
-
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
|
+
);
|
|
1067
1481
|
return { success: true, data: { brightness: br } };
|
|
1068
1482
|
}
|
|
1069
1483
|
try {
|
|
1070
|
-
const raw3 = await runShell(
|
|
1071
|
-
|
|
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
|
+
};
|
|
1072
1491
|
} catch {
|
|
1073
|
-
return {
|
|
1492
|
+
return {
|
|
1493
|
+
success: true,
|
|
1494
|
+
data: {
|
|
1495
|
+
brightness: "unknown",
|
|
1496
|
+
note: "Install 'brightness' via brew for control"
|
|
1497
|
+
}
|
|
1498
|
+
};
|
|
1074
1499
|
}
|
|
1075
1500
|
}
|
|
1076
1501
|
// ── Do Not Disturb ──────────────────────────────────────
|
|
@@ -1078,49 +1503,79 @@ print("\\(x),\\(y)")`;
|
|
|
1078
1503
|
const enabled = params.enabled;
|
|
1079
1504
|
if (enabled !== void 0) {
|
|
1080
1505
|
try {
|
|
1081
|
-
await runShell(
|
|
1082
|
-
|
|
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
|
+
};
|
|
1083
1513
|
} catch {
|
|
1084
|
-
return {
|
|
1514
|
+
return {
|
|
1515
|
+
success: true,
|
|
1516
|
+
data: {
|
|
1517
|
+
dnd: enabled,
|
|
1518
|
+
note: "Set DND manually in Control Center"
|
|
1519
|
+
}
|
|
1520
|
+
};
|
|
1085
1521
|
}
|
|
1086
1522
|
}
|
|
1087
|
-
return {
|
|
1523
|
+
return {
|
|
1524
|
+
success: true,
|
|
1525
|
+
data: { note: "Pass enabled: true/false to toggle DND" }
|
|
1526
|
+
};
|
|
1088
1527
|
}
|
|
1089
1528
|
// ── File Management ─────────────────────────────────────
|
|
1090
1529
|
case "sys_file_list": {
|
|
1091
1530
|
const dirPath = params.path || "Desktop";
|
|
1092
1531
|
const fullDir = safePath(dirPath);
|
|
1093
|
-
if (!fullDir)
|
|
1094
|
-
|
|
1532
|
+
if (!fullDir)
|
|
1533
|
+
return { success: false, error: `Access denied: ${dirPath}` };
|
|
1534
|
+
if (!existsSync(fullDir))
|
|
1535
|
+
return { success: false, error: `Directory not found: ${dirPath}` };
|
|
1095
1536
|
const entries = readdirSync(fullDir).map((name) => {
|
|
1096
1537
|
try {
|
|
1097
1538
|
const st = statSync(join(fullDir, name));
|
|
1098
|
-
return {
|
|
1539
|
+
return {
|
|
1540
|
+
name,
|
|
1541
|
+
type: st.isDirectory() ? "dir" : "file",
|
|
1542
|
+
size: st.size,
|
|
1543
|
+
modified: st.mtime.toISOString()
|
|
1544
|
+
};
|
|
1099
1545
|
} catch {
|
|
1100
1546
|
return { name, type: "unknown", size: 0, modified: "" };
|
|
1101
1547
|
}
|
|
1102
1548
|
});
|
|
1103
|
-
return {
|
|
1549
|
+
return {
|
|
1550
|
+
success: true,
|
|
1551
|
+
data: { path: dirPath, entries, count: entries.length }
|
|
1552
|
+
};
|
|
1104
1553
|
}
|
|
1105
1554
|
case "sys_file_move": {
|
|
1106
1555
|
const src = params.from;
|
|
1107
1556
|
const dst = params.to;
|
|
1108
|
-
if (!src || !dst)
|
|
1557
|
+
if (!src || !dst)
|
|
1558
|
+
return { success: false, error: "Missing from/to paths" };
|
|
1109
1559
|
const fullSrc = safePath(src);
|
|
1110
1560
|
const fullDst = safePath(dst);
|
|
1111
|
-
if (!fullSrc || !fullDst)
|
|
1112
|
-
|
|
1561
|
+
if (!fullSrc || !fullDst)
|
|
1562
|
+
return { success: false, error: "Access denied" };
|
|
1563
|
+
if (!existsSync(fullSrc))
|
|
1564
|
+
return { success: false, error: `Source not found: ${src}` };
|
|
1113
1565
|
renameSync(fullSrc, fullDst);
|
|
1114
1566
|
return { success: true, data: { moved: src, to: dst } };
|
|
1115
1567
|
}
|
|
1116
1568
|
case "sys_file_copy": {
|
|
1117
1569
|
const src2 = params.from;
|
|
1118
1570
|
const dst2 = params.to;
|
|
1119
|
-
if (!src2 || !dst2)
|
|
1571
|
+
if (!src2 || !dst2)
|
|
1572
|
+
return { success: false, error: "Missing from/to paths" };
|
|
1120
1573
|
const fullSrc2 = safePath(src2);
|
|
1121
1574
|
const fullDst2 = safePath(dst2);
|
|
1122
|
-
if (!fullSrc2 || !fullDst2)
|
|
1123
|
-
|
|
1575
|
+
if (!fullSrc2 || !fullDst2)
|
|
1576
|
+
return { success: false, error: "Access denied" };
|
|
1577
|
+
if (!existsSync(fullSrc2))
|
|
1578
|
+
return { success: false, error: `Source not found: ${src2}` };
|
|
1124
1579
|
copyFileSync(fullSrc2, fullDst2);
|
|
1125
1580
|
return { success: true, data: { copied: src2, to: dst2 } };
|
|
1126
1581
|
}
|
|
@@ -1129,16 +1584,23 @@ print("\\(x),\\(y)")`;
|
|
|
1129
1584
|
if (!target) return { success: false, error: "Missing path" };
|
|
1130
1585
|
const fullTarget = safePath(target);
|
|
1131
1586
|
if (!fullTarget) return { success: false, error: "Access denied" };
|
|
1132
|
-
if (!existsSync(fullTarget))
|
|
1133
|
-
|
|
1134
|
-
|
|
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
|
+
};
|
|
1135
1596
|
}
|
|
1136
1597
|
case "sys_file_info": {
|
|
1137
1598
|
const fPath = params.path;
|
|
1138
1599
|
if (!fPath) return { success: false, error: "Missing path" };
|
|
1139
1600
|
const fullF = safePath(fPath);
|
|
1140
1601
|
if (!fullF) return { success: false, error: "Access denied" };
|
|
1141
|
-
if (!existsSync(fullF))
|
|
1602
|
+
if (!existsSync(fullF))
|
|
1603
|
+
return { success: false, error: `Not found: ${fPath}` };
|
|
1142
1604
|
const st = statSync(fullF);
|
|
1143
1605
|
return {
|
|
1144
1606
|
success: true,
|
|
@@ -1161,9 +1623,15 @@ print("\\(x),\\(y)")`;
|
|
|
1161
1623
|
if (!dlUrl) return { success: false, error: "Missing URL" };
|
|
1162
1624
|
const fullDl = safePath(dlDest);
|
|
1163
1625
|
if (!fullDl) return { success: false, error: "Access denied" };
|
|
1164
|
-
await runShell(
|
|
1626
|
+
await runShell(
|
|
1627
|
+
`curl -sL -o "${fullDl}" "${dlUrl.replace(/"/g, '\\"')}"`,
|
|
1628
|
+
6e4
|
|
1629
|
+
);
|
|
1165
1630
|
const size = existsSync(fullDl) ? statSync(fullDl).size : 0;
|
|
1166
|
-
return {
|
|
1631
|
+
return {
|
|
1632
|
+
success: true,
|
|
1633
|
+
data: { downloaded: dlUrl, saved: dlDest, size }
|
|
1634
|
+
};
|
|
1167
1635
|
}
|
|
1168
1636
|
// ── Window Management ───────────────────────────────────
|
|
1169
1637
|
case "sys_window_list": {
|
|
@@ -1180,7 +1648,12 @@ print("\\(x),\\(y)")`;
|
|
|
1180
1648
|
end tell`);
|
|
1181
1649
|
const windows = raw4.split("\n").filter(Boolean).map((line) => {
|
|
1182
1650
|
const [app, title, pos, sz] = line.split(" | ");
|
|
1183
|
-
return {
|
|
1651
|
+
return {
|
|
1652
|
+
app: app?.trim(),
|
|
1653
|
+
title: title?.trim(),
|
|
1654
|
+
position: pos?.trim(),
|
|
1655
|
+
size: sz?.trim()
|
|
1656
|
+
};
|
|
1184
1657
|
});
|
|
1185
1658
|
return { success: true, data: { windows, count: windows.length } };
|
|
1186
1659
|
}
|
|
@@ -1203,7 +1676,11 @@ print("\\(x),\\(y)")`;
|
|
|
1203
1676
|
if (!app2) return { success: false, error: "Missing app name" };
|
|
1204
1677
|
const posPart = x !== void 0 && y !== void 0 ? `set position of window 1 to {${x}, ${y}}` : "";
|
|
1205
1678
|
const sizePart = w !== void 0 && h !== void 0 ? `set size of window 1 to {${w}, ${h}}` : "";
|
|
1206
|
-
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
|
+
};
|
|
1207
1684
|
await runAppleScript(`
|
|
1208
1685
|
tell application "System Events"
|
|
1209
1686
|
tell process "${app2.replace(/"/g, '\\"')}"
|
|
@@ -1211,7 +1688,14 @@ print("\\(x),\\(y)")`;
|
|
|
1211
1688
|
${sizePart}
|
|
1212
1689
|
end tell
|
|
1213
1690
|
end tell`);
|
|
1214
|
-
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
|
+
};
|
|
1215
1699
|
}
|
|
1216
1700
|
// ── Apple Notes ─────────────────────────────────────────
|
|
1217
1701
|
case "sys_notes_list": {
|
|
@@ -1227,7 +1711,11 @@ print("\\(x),\\(y)")`;
|
|
|
1227
1711
|
end tell`);
|
|
1228
1712
|
const notes = raw5.split("\n").filter(Boolean).map((line) => {
|
|
1229
1713
|
const [id, name, date] = line.split(" | ");
|
|
1230
|
-
return {
|
|
1714
|
+
return {
|
|
1715
|
+
id: id?.trim(),
|
|
1716
|
+
name: name?.trim(),
|
|
1717
|
+
modified: date?.trim()
|
|
1718
|
+
};
|
|
1231
1719
|
});
|
|
1232
1720
|
return { success: true, data: { notes, count: notes.length } };
|
|
1233
1721
|
}
|
|
@@ -1267,9 +1755,16 @@ print("\\(x),\\(y)")`;
|
|
|
1267
1755
|
end tell`);
|
|
1268
1756
|
const contacts = raw6.split("\n").filter(Boolean).map((line) => {
|
|
1269
1757
|
const [name, email, phone] = line.split(" | ");
|
|
1270
|
-
return {
|
|
1758
|
+
return {
|
|
1759
|
+
name: name?.trim(),
|
|
1760
|
+
email: email?.trim(),
|
|
1761
|
+
phone: phone?.trim()
|
|
1762
|
+
};
|
|
1271
1763
|
});
|
|
1272
|
-
return {
|
|
1764
|
+
return {
|
|
1765
|
+
success: true,
|
|
1766
|
+
data: { contacts, count: contacts.length, query: query2 }
|
|
1767
|
+
};
|
|
1273
1768
|
}
|
|
1274
1769
|
// ── OCR (Vision framework) ──────────────────────────────
|
|
1275
1770
|
case "sys_ocr": {
|
|
@@ -1277,7 +1772,8 @@ print("\\(x),\\(y)")`;
|
|
|
1277
1772
|
if (!imgPath) return { success: false, error: "Missing image path" };
|
|
1278
1773
|
const fullImg = imgPath.startsWith("/tmp/") ? imgPath : safePath(imgPath);
|
|
1279
1774
|
if (!fullImg) return { success: false, error: "Access denied" };
|
|
1280
|
-
if (!existsSync(fullImg))
|
|
1775
|
+
if (!existsSync(fullImg))
|
|
1776
|
+
return { success: false, error: `Image not found: ${imgPath}` };
|
|
1281
1777
|
const swiftOcr = `
|
|
1282
1778
|
import Foundation
|
|
1283
1779
|
import Vision
|
|
@@ -1299,10 +1795,311 @@ let text = results.compactMap { $0.topCandidates(1).first?.string }.joined(separ
|
|
|
1299
1795
|
print(text)`;
|
|
1300
1796
|
try {
|
|
1301
1797
|
const ocrText = await runSwift(swiftOcr, 3e4);
|
|
1302
|
-
return {
|
|
1798
|
+
return {
|
|
1799
|
+
success: true,
|
|
1800
|
+
data: {
|
|
1801
|
+
text: ocrText.slice(0, 1e4),
|
|
1802
|
+
length: ocrText.length,
|
|
1803
|
+
path: imgPath
|
|
1804
|
+
}
|
|
1805
|
+
};
|
|
1303
1806
|
} catch (err) {
|
|
1304
|
-
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
|
+
}
|
|
1305
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
|
+
};
|
|
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 } };
|
|
1306
2103
|
}
|
|
1307
2104
|
default:
|
|
1308
2105
|
return { success: false, error: `Unknown command: ${command}` };
|
|
@@ -1311,6 +2108,130 @@ print(text)`;
|
|
|
1311
2108
|
return { success: false, error: err.message };
|
|
1312
2109
|
}
|
|
1313
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
|
+
}
|
|
1314
2235
|
var ws = null;
|
|
1315
2236
|
var reconnectTimer = null;
|
|
1316
2237
|
var heartbeatTimer = null;
|
|
@@ -1323,12 +2244,16 @@ function connect() {
|
|
|
1323
2244
|
ws.on("open", () => {
|
|
1324
2245
|
reconnectAttempts = 0;
|
|
1325
2246
|
console.log("\u2705 Connected to Pulso!");
|
|
1326
|
-
console.log(
|
|
2247
|
+
console.log(
|
|
2248
|
+
`\u{1F5A5}\uFE0F Companion is active \u2014 ${ACCESS_LEVEL === "full" ? "full device access" : "sandboxed mode"}`
|
|
2249
|
+
);
|
|
1327
2250
|
console.log("");
|
|
1328
2251
|
console.log(" Available capabilities:");
|
|
1329
2252
|
console.log(" \u2022 Open apps & URLs");
|
|
1330
2253
|
console.log(" \u2022 Control Spotify & media");
|
|
1331
|
-
console.log(
|
|
2254
|
+
console.log(
|
|
2255
|
+
` \u2022 Read/write files ${ACCESS_LEVEL === "full" ? "(full device)" : "(Documents, Desktop, Downloads)"}`
|
|
2256
|
+
);
|
|
1332
2257
|
console.log(" \u2022 Clipboard access");
|
|
1333
2258
|
console.log(" \u2022 Screenshots");
|
|
1334
2259
|
console.log(" \u2022 Text-to-speech");
|
|
@@ -1337,8 +2262,12 @@ function connect() {
|
|
|
1337
2262
|
console.log(" \u2022 Terminal commands");
|
|
1338
2263
|
console.log(" \u2022 System notifications");
|
|
1339
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)");
|
|
1340
2267
|
console.log("");
|
|
1341
|
-
console.log(
|
|
2268
|
+
console.log(
|
|
2269
|
+
` Access: ${ACCESS_LEVEL === "full" ? "\u{1F513} Full (unrestricted)" : "\u{1F512} Sandboxed (safe dirs only)"}`
|
|
2270
|
+
);
|
|
1342
2271
|
console.log(" Waiting for commands from Pulso agent...");
|
|
1343
2272
|
ws.send(JSON.stringify({ type: "extension_ready" }));
|
|
1344
2273
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
@@ -1359,10 +2288,16 @@ function connect() {
|
|
|
1359
2288
|
return;
|
|
1360
2289
|
}
|
|
1361
2290
|
if (msg.id && msg.command) {
|
|
1362
|
-
console.log(
|
|
1363
|
-
|
|
2291
|
+
console.log(
|
|
2292
|
+
`
|
|
2293
|
+
\u26A1 Command: ${msg.command}`,
|
|
2294
|
+
msg.params ? JSON.stringify(msg.params).slice(0, 200) : ""
|
|
2295
|
+
);
|
|
1364
2296
|
const result = await handleCommand(msg.command, msg.params ?? {});
|
|
1365
|
-
console.log(
|
|
2297
|
+
console.log(
|
|
2298
|
+
` \u2192 ${result.success ? "\u2705" : "\u274C"}`,
|
|
2299
|
+
result.success ? JSON.stringify(result.data).slice(0, 200) : result.error
|
|
2300
|
+
);
|
|
1366
2301
|
ws.send(JSON.stringify({ id: msg.id, result }));
|
|
1367
2302
|
return;
|
|
1368
2303
|
}
|
|
@@ -1380,9 +2315,15 @@ function connect() {
|
|
|
1380
2315
|
heartbeatTimer = null;
|
|
1381
2316
|
}
|
|
1382
2317
|
if (reasonStr === "New connection from same user") {
|
|
1383
|
-
console.log(
|
|
1384
|
-
|
|
1385
|
-
|
|
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
|
+
);
|
|
1386
2327
|
process.exit(0);
|
|
1387
2328
|
return;
|
|
1388
2329
|
}
|
|
@@ -1395,29 +2336,209 @@ function connect() {
|
|
|
1395
2336
|
function scheduleReconnect() {
|
|
1396
2337
|
if (reconnectTimer) return;
|
|
1397
2338
|
reconnectAttempts++;
|
|
1398
|
-
const delay = Math.min(
|
|
1399
|
-
|
|
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
|
+
);
|
|
1400
2346
|
reconnectTimer = setTimeout(() => {
|
|
1401
2347
|
reconnectTimer = null;
|
|
1402
2348
|
connect();
|
|
1403
2349
|
}, delay);
|
|
1404
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
|
+
}
|
|
1405
2521
|
console.log("");
|
|
1406
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");
|
|
1407
|
-
console.log(" \u2551 \u{1FAC0} Pulso Mac Companion v0.2.
|
|
2523
|
+
console.log(" \u2551 \u{1FAC0} Pulso Mac Companion v0.2.3 \u2551");
|
|
1408
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");
|
|
1409
2525
|
console.log("");
|
|
1410
2526
|
setupPermissions().then(() => {
|
|
1411
2527
|
connect();
|
|
2528
|
+
if (WAKE_WORD_ENABLED) {
|
|
2529
|
+
startWakeWordDetection();
|
|
2530
|
+
}
|
|
1412
2531
|
}).catch(() => {
|
|
1413
2532
|
connect();
|
|
1414
2533
|
});
|
|
1415
2534
|
process.on("SIGINT", () => {
|
|
1416
2535
|
console.log("\n\u{1F44B} Shutting down Pulso Companion...");
|
|
2536
|
+
wakeWordActive = false;
|
|
1417
2537
|
ws?.close(1e3, "User shutdown");
|
|
1418
2538
|
process.exit(0);
|
|
1419
2539
|
});
|
|
1420
2540
|
process.on("SIGTERM", () => {
|
|
2541
|
+
wakeWordActive = false;
|
|
1421
2542
|
ws?.close(1e3, "Process terminated");
|
|
1422
2543
|
process.exit(0);
|
|
1423
2544
|
});
|