@pulso/companion 0.2.2 → 0.2.4
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 +1341 -179
- 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
|
}
|
|
@@ -472,21 +681,48 @@ async function handleCommand(command, params) {
|
|
|
472
681
|
const ts = Date.now();
|
|
473
682
|
const pngPath = `/tmp/pulso-ss-${ts}.png`;
|
|
474
683
|
const jpgPath = `/tmp/pulso-ss-${ts}.jpg`;
|
|
475
|
-
await runShell(`screencapture -x ${pngPath}`, 15e3);
|
|
476
|
-
if (!existsSync(pngPath)) return { success: false, error: "Screenshot failed" };
|
|
477
684
|
try {
|
|
478
|
-
await runShell(`
|
|
685
|
+
await runShell(`screencapture -x ${pngPath}`, 15e3);
|
|
686
|
+
} catch (ssErr) {
|
|
687
|
+
const msg = ssErr.message || "";
|
|
688
|
+
if (msg.includes("could not create image") || msg.includes("display")) {
|
|
689
|
+
return {
|
|
690
|
+
success: false,
|
|
691
|
+
error: "Screen Recording permission required. Go to System Settings \u2192 Privacy & Security \u2192 Screen Recording \u2192 enable Terminal (or your terminal app)."
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
return { success: false, error: `Screenshot failed: ${msg}` };
|
|
695
|
+
}
|
|
696
|
+
if (!existsSync(pngPath))
|
|
697
|
+
return {
|
|
698
|
+
success: false,
|
|
699
|
+
error: "Screenshot failed \u2014 Screen Recording permission may be needed. Go to System Settings \u2192 Privacy & Security \u2192 Screen Recording."
|
|
700
|
+
};
|
|
701
|
+
try {
|
|
702
|
+
await runShell(
|
|
703
|
+
`sips --resampleWidth 1280 --setProperty format jpeg --setProperty formatOptions 60 ${pngPath} --out ${jpgPath}`,
|
|
704
|
+
1e4
|
|
705
|
+
);
|
|
479
706
|
} catch {
|
|
480
707
|
const buf2 = readFileSync(pngPath);
|
|
481
708
|
exec(`rm -f ${pngPath}`);
|
|
482
|
-
return {
|
|
709
|
+
return {
|
|
710
|
+
success: true,
|
|
711
|
+
data: {
|
|
712
|
+
image: `data:image/png;base64,${buf2.toString("base64")}`,
|
|
713
|
+
format: "png",
|
|
714
|
+
note: "Full screen screenshot"
|
|
715
|
+
}
|
|
716
|
+
};
|
|
483
717
|
}
|
|
484
718
|
const buf = readFileSync(jpgPath);
|
|
485
719
|
const base64 = buf.toString("base64");
|
|
486
720
|
exec(`rm -f ${pngPath} ${jpgPath}`);
|
|
487
721
|
let screenSize = "unknown";
|
|
488
722
|
try {
|
|
489
|
-
screenSize = await runShell(
|
|
723
|
+
screenSize = await runShell(
|
|
724
|
+
`system_profiler SPDisplaysDataType 2>/dev/null | grep Resolution | head -1 | sed 's/.*: //'`
|
|
725
|
+
);
|
|
490
726
|
} catch {
|
|
491
727
|
}
|
|
492
728
|
return {
|
|
@@ -505,7 +741,8 @@ async function handleCommand(command, params) {
|
|
|
505
741
|
const x = Number(params.x);
|
|
506
742
|
const y = Number(params.y);
|
|
507
743
|
const button = params.button || "left";
|
|
508
|
-
if (isNaN(x) || isNaN(y))
|
|
744
|
+
if (isNaN(x) || isNaN(y))
|
|
745
|
+
return { success: false, error: "Missing x, y coordinates" };
|
|
509
746
|
const mouseType = button === "right" ? "rightMouseDown" : "leftMouseDown";
|
|
510
747
|
const mouseTypeUp = button === "right" ? "rightMouseUp" : "leftMouseUp";
|
|
511
748
|
const mouseButton = button === "right" ? ".right" : ".left";
|
|
@@ -524,7 +761,8 @@ print("clicked")`;
|
|
|
524
761
|
case "sys_mouse_double_click": {
|
|
525
762
|
const x = Number(params.x);
|
|
526
763
|
const y = Number(params.y);
|
|
527
|
-
if (isNaN(x) || isNaN(y))
|
|
764
|
+
if (isNaN(x) || isNaN(y))
|
|
765
|
+
return { success: false, error: "Missing x, y coordinates" };
|
|
528
766
|
const swift = `
|
|
529
767
|
import Cocoa
|
|
530
768
|
let p = CGPoint(x: ${x}, y: ${y})
|
|
@@ -552,7 +790,8 @@ print("double-clicked")`;
|
|
|
552
790
|
const y = Number(params.y) || 0;
|
|
553
791
|
const scrollY = Number(params.scrollY) || 0;
|
|
554
792
|
const scrollX = Number(params.scrollX) || 0;
|
|
555
|
-
if (!scrollY && !scrollX)
|
|
793
|
+
if (!scrollY && !scrollX)
|
|
794
|
+
return { success: false, error: "Missing scrollY or scrollX" };
|
|
556
795
|
const swift = `
|
|
557
796
|
import Cocoa
|
|
558
797
|
let p = CGPoint(x: ${x}, y: ${y})
|
|
@@ -563,12 +802,17 @@ let scroll = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2,
|
|
|
563
802
|
scroll.post(tap: .cghidEventTap)
|
|
564
803
|
print("scrolled")`;
|
|
565
804
|
await runSwift(swift);
|
|
566
|
-
return {
|
|
805
|
+
return {
|
|
806
|
+
success: true,
|
|
807
|
+
data: { scrolled: { x, y, scrollY, scrollX } }
|
|
808
|
+
};
|
|
567
809
|
}
|
|
568
810
|
case "sys_keyboard_type": {
|
|
569
811
|
const text = params.text;
|
|
570
812
|
if (!text) return { success: false, error: "Missing text" };
|
|
571
|
-
await runAppleScript(
|
|
813
|
+
await runAppleScript(
|
|
814
|
+
`tell application "System Events" to keystroke "${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
|
|
815
|
+
);
|
|
572
816
|
return { success: true, data: { typed: text.slice(0, 100) } };
|
|
573
817
|
}
|
|
574
818
|
case "sys_key_press": {
|
|
@@ -619,19 +863,27 @@ print("scrolled")`;
|
|
|
619
863
|
const keyCode = keyCodeMap[key.toLowerCase()];
|
|
620
864
|
if (keyCode !== void 0) {
|
|
621
865
|
const using = modStr.length > 0 ? ` using {${modStr.join(", ")}}` : "";
|
|
622
|
-
await runAppleScript(
|
|
866
|
+
await runAppleScript(
|
|
867
|
+
`tell application "System Events" to key code ${keyCode}${using}`
|
|
868
|
+
);
|
|
623
869
|
} else if (key.length === 1) {
|
|
624
870
|
const using = modStr.length > 0 ? ` using {${modStr.join(", ")}}` : "";
|
|
625
|
-
await runAppleScript(
|
|
871
|
+
await runAppleScript(
|
|
872
|
+
`tell application "System Events" to keystroke "${key}"${using}`
|
|
873
|
+
);
|
|
626
874
|
} else {
|
|
627
|
-
return {
|
|
875
|
+
return {
|
|
876
|
+
success: false,
|
|
877
|
+
error: `Unknown key: ${key}. Use single characters or: enter, tab, escape, delete, space, up, down, left, right, f1-f12, home, end, pageup, pagedown`
|
|
878
|
+
};
|
|
628
879
|
}
|
|
629
880
|
return { success: true, data: { pressed: key, modifiers } };
|
|
630
881
|
}
|
|
631
882
|
case "sys_mouse_move": {
|
|
632
883
|
const x = Number(params.x);
|
|
633
884
|
const y = Number(params.y);
|
|
634
|
-
if (isNaN(x) || isNaN(y))
|
|
885
|
+
if (isNaN(x) || isNaN(y))
|
|
886
|
+
return { success: false, error: "Missing x, y coordinates" };
|
|
635
887
|
const swift = `
|
|
636
888
|
import Cocoa
|
|
637
889
|
let p = CGPoint(x: ${x}, y: ${y})
|
|
@@ -646,7 +898,8 @@ print("moved")`;
|
|
|
646
898
|
const fromY = Number(params.fromY);
|
|
647
899
|
const toX = Number(params.toX);
|
|
648
900
|
const toY = Number(params.toY);
|
|
649
|
-
if ([fromX, fromY, toX, toY].some(isNaN))
|
|
901
|
+
if ([fromX, fromY, toX, toY].some(isNaN))
|
|
902
|
+
return { success: false, error: "Missing fromX, fromY, toX, toY" };
|
|
650
903
|
const swift = `
|
|
651
904
|
import Cocoa
|
|
652
905
|
let from = CGPoint(x: ${fromX}, y: ${fromY})
|
|
@@ -666,7 +919,12 @@ let u = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosit
|
|
|
666
919
|
u.post(tap: .cghidEventTap)
|
|
667
920
|
print("dragged")`;
|
|
668
921
|
await runSwift(swift);
|
|
669
|
-
return {
|
|
922
|
+
return {
|
|
923
|
+
success: true,
|
|
924
|
+
data: {
|
|
925
|
+
dragged: { from: { x: fromX, y: fromY }, to: { x: toX, y: toY } }
|
|
926
|
+
}
|
|
927
|
+
};
|
|
670
928
|
}
|
|
671
929
|
case "sys_get_cursor_position": {
|
|
672
930
|
const swift = `
|
|
@@ -684,7 +942,13 @@ print("\\(x),\\(y)")`;
|
|
|
684
942
|
}
|
|
685
943
|
// ── Browser Automation ─────────────────────────────────
|
|
686
944
|
case "sys_browser_list_tabs": {
|
|
687
|
-
const browsers = [
|
|
945
|
+
const browsers = [
|
|
946
|
+
"Google Chrome",
|
|
947
|
+
"Safari",
|
|
948
|
+
"Arc",
|
|
949
|
+
"Firefox",
|
|
950
|
+
"Microsoft Edge"
|
|
951
|
+
];
|
|
688
952
|
const allTabs = [];
|
|
689
953
|
for (const browser of browsers) {
|
|
690
954
|
try {
|
|
@@ -708,7 +972,12 @@ print("\\(x),\\(y)")`;
|
|
|
708
972
|
const tabStr = rest.join("~~~");
|
|
709
973
|
const pairs = tabStr.split("|||").filter(Boolean);
|
|
710
974
|
for (let i = 0; i < pairs.length - 1; i += 2) {
|
|
711
|
-
allTabs.push({
|
|
975
|
+
allTabs.push({
|
|
976
|
+
browser: "Safari",
|
|
977
|
+
title: pairs[i],
|
|
978
|
+
url: pairs[i + 1],
|
|
979
|
+
active: pairs[i + 1] === activeURL.trim()
|
|
980
|
+
});
|
|
712
981
|
}
|
|
713
982
|
} else {
|
|
714
983
|
const tabData = await runAppleScript(`
|
|
@@ -726,13 +995,21 @@ print("\\(x),\\(y)")`;
|
|
|
726
995
|
const tabStr = rest.join("~~~");
|
|
727
996
|
const pairs = tabStr.split("|||").filter(Boolean);
|
|
728
997
|
for (let i = 0; i < pairs.length - 1; i += 2) {
|
|
729
|
-
allTabs.push({
|
|
998
|
+
allTabs.push({
|
|
999
|
+
browser,
|
|
1000
|
+
title: pairs[i],
|
|
1001
|
+
url: pairs[i + 1],
|
|
1002
|
+
active: pairs[i + 1] === activeURL.trim()
|
|
1003
|
+
});
|
|
730
1004
|
}
|
|
731
1005
|
}
|
|
732
1006
|
} catch {
|
|
733
1007
|
}
|
|
734
1008
|
}
|
|
735
|
-
return {
|
|
1009
|
+
return {
|
|
1010
|
+
success: true,
|
|
1011
|
+
data: { tabs: allTabs, count: allTabs.length }
|
|
1012
|
+
};
|
|
736
1013
|
}
|
|
737
1014
|
case "sys_browser_navigate": {
|
|
738
1015
|
const url = params.url;
|
|
@@ -760,7 +1037,10 @@ print("\\(x),\\(y)")`;
|
|
|
760
1037
|
}
|
|
761
1038
|
return { success: true, data: { navigated: url, browser } };
|
|
762
1039
|
} catch (err) {
|
|
763
|
-
return {
|
|
1040
|
+
return {
|
|
1041
|
+
success: false,
|
|
1042
|
+
error: `Failed to navigate: ${err.message}`
|
|
1043
|
+
};
|
|
764
1044
|
}
|
|
765
1045
|
}
|
|
766
1046
|
case "sys_browser_new_tab": {
|
|
@@ -783,7 +1063,10 @@ print("\\(x),\\(y)")`;
|
|
|
783
1063
|
}
|
|
784
1064
|
return { success: true, data: { opened: url, browser } };
|
|
785
1065
|
} catch (err) {
|
|
786
|
-
return {
|
|
1066
|
+
return {
|
|
1067
|
+
success: false,
|
|
1068
|
+
error: `Failed to open tab: ${err.message}`
|
|
1069
|
+
};
|
|
787
1070
|
}
|
|
788
1071
|
}
|
|
789
1072
|
case "sys_browser_read_page": {
|
|
@@ -809,32 +1092,53 @@ print("\\(x),\\(y)")`;
|
|
|
809
1092
|
}
|
|
810
1093
|
} catch {
|
|
811
1094
|
try {
|
|
812
|
-
const savedClipboard = await runShell(
|
|
813
|
-
|
|
1095
|
+
const savedClipboard = await runShell(
|
|
1096
|
+
"pbpaste 2>/dev/null || true"
|
|
1097
|
+
);
|
|
1098
|
+
await runAppleScript(
|
|
1099
|
+
`tell application "${browser.replace(/"/g, '\\"')}" to activate`
|
|
1100
|
+
);
|
|
814
1101
|
await new Promise((r) => setTimeout(r, 300));
|
|
815
|
-
await runAppleScript(
|
|
1102
|
+
await runAppleScript(
|
|
1103
|
+
'tell application "System Events" to keystroke "a" using command down'
|
|
1104
|
+
);
|
|
816
1105
|
await new Promise((r) => setTimeout(r, 200));
|
|
817
|
-
await runAppleScript(
|
|
1106
|
+
await runAppleScript(
|
|
1107
|
+
'tell application "System Events" to keystroke "c" using command down'
|
|
1108
|
+
);
|
|
818
1109
|
await new Promise((r) => setTimeout(r, 300));
|
|
819
1110
|
content = await runShell("pbpaste");
|
|
820
1111
|
method = "clipboard";
|
|
821
|
-
await runAppleScript(
|
|
1112
|
+
await runAppleScript(
|
|
1113
|
+
'tell application "System Events" to key code 53'
|
|
1114
|
+
);
|
|
822
1115
|
if (savedClipboard && savedClipboard !== content) {
|
|
823
1116
|
execSync(`echo ${JSON.stringify(savedClipboard)} | pbcopy`);
|
|
824
1117
|
}
|
|
825
1118
|
} catch (clipErr) {
|
|
826
|
-
return {
|
|
1119
|
+
return {
|
|
1120
|
+
success: false,
|
|
1121
|
+
error: `Could not read page. Enable 'Allow JavaScript from Apple Events' in ${browser} or grant Accessibility permission. Error: ${clipErr.message}`
|
|
1122
|
+
};
|
|
827
1123
|
}
|
|
828
1124
|
}
|
|
829
1125
|
let pageUrl = "";
|
|
830
1126
|
let pageTitle = "";
|
|
831
1127
|
try {
|
|
832
1128
|
if (browser === "Safari") {
|
|
833
|
-
pageUrl = await runAppleScript(
|
|
834
|
-
|
|
1129
|
+
pageUrl = await runAppleScript(
|
|
1130
|
+
'tell application "Safari" to return URL of front document'
|
|
1131
|
+
);
|
|
1132
|
+
pageTitle = await runAppleScript(
|
|
1133
|
+
'tell application "Safari" to return name of front document'
|
|
1134
|
+
);
|
|
835
1135
|
} else {
|
|
836
|
-
pageUrl = await runAppleScript(
|
|
837
|
-
|
|
1136
|
+
pageUrl = await runAppleScript(
|
|
1137
|
+
`tell application "${browser.replace(/"/g, '\\"')}" to return URL of active tab of front window`
|
|
1138
|
+
);
|
|
1139
|
+
pageTitle = await runAppleScript(
|
|
1140
|
+
`tell application "${browser.replace(/"/g, '\\"')}" to return title of active tab of front window`
|
|
1141
|
+
);
|
|
838
1142
|
}
|
|
839
1143
|
} catch {
|
|
840
1144
|
}
|
|
@@ -857,13 +1161,20 @@ print("\\(x),\\(y)")`;
|
|
|
857
1161
|
try {
|
|
858
1162
|
let result;
|
|
859
1163
|
if (browser === "Safari") {
|
|
860
|
-
result = await runAppleScript(
|
|
1164
|
+
result = await runAppleScript(
|
|
1165
|
+
`tell application "Safari" to do JavaScript ${JSON.stringify(js)} in front document`
|
|
1166
|
+
);
|
|
861
1167
|
} else {
|
|
862
|
-
result = await runAppleScript(
|
|
1168
|
+
result = await runAppleScript(
|
|
1169
|
+
`tell application "${browser.replace(/"/g, '\\"')}" to execute javascript ${JSON.stringify(js)} in active tab of front window`
|
|
1170
|
+
);
|
|
863
1171
|
}
|
|
864
1172
|
return { success: true, data: { result: result.slice(0, 5e3) } };
|
|
865
1173
|
} catch (err) {
|
|
866
|
-
return {
|
|
1174
|
+
return {
|
|
1175
|
+
success: false,
|
|
1176
|
+
error: `JS execution failed (enable 'Allow JavaScript from Apple Events' in browser): ${err.message}`
|
|
1177
|
+
};
|
|
867
1178
|
}
|
|
868
1179
|
}
|
|
869
1180
|
// ── Email ──────────────────────────────────────────────
|
|
@@ -872,11 +1183,20 @@ print("\\(x),\\(y)")`;
|
|
|
872
1183
|
const subject = params.subject;
|
|
873
1184
|
const body = params.body;
|
|
874
1185
|
const method = params.method || "mail";
|
|
875
|
-
if (!to || !subject || !body)
|
|
1186
|
+
if (!to || !subject || !body)
|
|
1187
|
+
return { success: false, error: "Missing to, subject, or body" };
|
|
876
1188
|
if (method === "gmail") {
|
|
877
1189
|
const gmailUrl = `https://mail.google.com/mail/u/0/?view=cm&fs=1&to=${encodeURIComponent(to)}&su=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
|
878
1190
|
await runShell(`open "${gmailUrl}"`);
|
|
879
|
-
return {
|
|
1191
|
+
return {
|
|
1192
|
+
success: true,
|
|
1193
|
+
data: {
|
|
1194
|
+
method: "gmail",
|
|
1195
|
+
to,
|
|
1196
|
+
subject,
|
|
1197
|
+
note: "Gmail compose opened. User needs to click Send."
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
880
1200
|
}
|
|
881
1201
|
try {
|
|
882
1202
|
await runAppleScript(`
|
|
@@ -887,11 +1207,22 @@ print("\\(x),\\(y)")`;
|
|
|
887
1207
|
end tell
|
|
888
1208
|
send newMessage
|
|
889
1209
|
end tell`);
|
|
890
|
-
return {
|
|
1210
|
+
return {
|
|
1211
|
+
success: true,
|
|
1212
|
+
data: { method: "mail", to, subject, sent: true }
|
|
1213
|
+
};
|
|
891
1214
|
} catch (err) {
|
|
892
1215
|
const gmailUrl = `https://mail.google.com/mail/u/0/?view=cm&fs=1&to=${encodeURIComponent(to)}&su=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
|
893
1216
|
await runShell(`open "${gmailUrl}"`);
|
|
894
|
-
return {
|
|
1217
|
+
return {
|
|
1218
|
+
success: true,
|
|
1219
|
+
data: {
|
|
1220
|
+
method: "gmail_fallback",
|
|
1221
|
+
to,
|
|
1222
|
+
subject,
|
|
1223
|
+
note: `Mail.app failed (${err.message}). Gmail compose opened instead.`
|
|
1224
|
+
}
|
|
1225
|
+
};
|
|
895
1226
|
}
|
|
896
1227
|
}
|
|
897
1228
|
case "sys_run_shortcut": {
|
|
@@ -899,29 +1230,54 @@ print("\\(x),\\(y)")`;
|
|
|
899
1230
|
const input = params.input;
|
|
900
1231
|
if (!name) return { success: false, error: "Missing shortcut name" };
|
|
901
1232
|
const inputFlag = input ? `--input-type text --input "${input.replace(/"/g, '\\"')}"` : "";
|
|
902
|
-
const result = await runShell(
|
|
903
|
-
|
|
1233
|
+
const result = await runShell(
|
|
1234
|
+
`shortcuts run "${name.replace(/"/g, '\\"')}" ${inputFlag}`,
|
|
1235
|
+
3e4
|
|
1236
|
+
);
|
|
1237
|
+
return {
|
|
1238
|
+
success: true,
|
|
1239
|
+
data: { shortcut: name, output: result || "Shortcut executed" }
|
|
1240
|
+
};
|
|
904
1241
|
}
|
|
905
1242
|
// ── Shell Execution ─────────────────────────────────────
|
|
906
1243
|
case "sys_shell": {
|
|
907
1244
|
const cmd = params.command;
|
|
908
1245
|
if (!cmd) return { success: false, error: "Missing command" };
|
|
909
|
-
const blocked = [
|
|
910
|
-
|
|
1246
|
+
const blocked = [
|
|
1247
|
+
"rm -rf /",
|
|
1248
|
+
"mkfs",
|
|
1249
|
+
"dd if=",
|
|
1250
|
+
"> /dev/",
|
|
1251
|
+
":(){ :|:& };:"
|
|
1252
|
+
];
|
|
1253
|
+
if (blocked.some((b) => cmd.includes(b)))
|
|
1254
|
+
return { success: false, error: "Command blocked for safety" };
|
|
911
1255
|
const timeout = Number(params.timeout) || 15e3;
|
|
912
1256
|
try {
|
|
913
1257
|
const output = await runShell(cmd, timeout);
|
|
914
|
-
return {
|
|
1258
|
+
return {
|
|
1259
|
+
success: true,
|
|
1260
|
+
data: {
|
|
1261
|
+
command: cmd,
|
|
1262
|
+
output: output.slice(0, 1e4),
|
|
1263
|
+
truncated: output.length > 1e4
|
|
1264
|
+
}
|
|
1265
|
+
};
|
|
915
1266
|
} catch (err) {
|
|
916
|
-
return {
|
|
1267
|
+
return {
|
|
1268
|
+
success: false,
|
|
1269
|
+
error: `Shell error: ${err.message.slice(0, 2e3)}`
|
|
1270
|
+
};
|
|
917
1271
|
}
|
|
918
1272
|
}
|
|
919
1273
|
// ── Calendar ────────────────────────────────────────────
|
|
920
1274
|
case "sys_calendar_list": {
|
|
921
1275
|
const days = Number(params.days) || 7;
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1276
|
+
try {
|
|
1277
|
+
await runAppleScript(`tell application "Calendar" to launch`);
|
|
1278
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1279
|
+
} catch {
|
|
1280
|
+
}
|
|
925
1281
|
const script = `
|
|
926
1282
|
set output to ""
|
|
927
1283
|
tell application "Calendar"
|
|
@@ -937,9 +1293,17 @@ print("\\(x),\\(y)")`;
|
|
|
937
1293
|
const raw = await runAppleScript(script);
|
|
938
1294
|
const events = raw.split("\n").filter(Boolean).map((line) => {
|
|
939
1295
|
const [cal, summary, start, end] = line.split(" | ");
|
|
940
|
-
return {
|
|
1296
|
+
return {
|
|
1297
|
+
calendar: cal?.trim(),
|
|
1298
|
+
summary: summary?.trim(),
|
|
1299
|
+
start: start?.trim(),
|
|
1300
|
+
end: end?.trim()
|
|
1301
|
+
};
|
|
941
1302
|
});
|
|
942
|
-
return {
|
|
1303
|
+
return {
|
|
1304
|
+
success: true,
|
|
1305
|
+
data: { events, count: events.length, daysAhead: days }
|
|
1306
|
+
};
|
|
943
1307
|
}
|
|
944
1308
|
case "sys_calendar_create": {
|
|
945
1309
|
const summary = params.summary || params.title;
|
|
@@ -947,19 +1311,50 @@ print("\\(x),\\(y)")`;
|
|
|
947
1311
|
const endStr = params.end;
|
|
948
1312
|
const calendar = params.calendar || "";
|
|
949
1313
|
const notes = params.notes || "";
|
|
950
|
-
if (!summary || !startStr)
|
|
1314
|
+
if (!summary || !startStr)
|
|
1315
|
+
return { success: false, error: "Missing summary or start time" };
|
|
1316
|
+
const parseDate = (iso) => {
|
|
1317
|
+
const d = new Date(iso);
|
|
1318
|
+
if (isNaN(d.getTime())) return null;
|
|
1319
|
+
return { y: d.getFullYear(), mo: d.getMonth() + 1, d: d.getDate(), h: d.getHours(), mi: d.getMinutes() };
|
|
1320
|
+
};
|
|
1321
|
+
const buildDateScript = (varName, iso) => {
|
|
1322
|
+
const p = parseDate(iso);
|
|
1323
|
+
if (!p) return "";
|
|
1324
|
+
return `set ${varName} to current date
|
|
1325
|
+
set year of ${varName} to ${p.y}
|
|
1326
|
+
set month of ${varName} to ${p.mo}
|
|
1327
|
+
set day of ${varName} to ${p.d}
|
|
1328
|
+
set hours of ${varName} to ${p.h}
|
|
1329
|
+
set minutes of ${varName} to ${p.mi}
|
|
1330
|
+
set seconds of ${varName} to 0`;
|
|
1331
|
+
};
|
|
1332
|
+
const startDateScript = buildDateScript("startD", startStr);
|
|
1333
|
+
if (!startDateScript)
|
|
1334
|
+
return { success: false, error: `Invalid start date: ${startStr}` };
|
|
1335
|
+
const endDateScript = endStr ? buildDateScript("endD", endStr) : "";
|
|
951
1336
|
const calTarget = calendar ? `calendar "${calendar.replace(/"/g, '\\"')}"` : "default calendar";
|
|
952
|
-
const
|
|
953
|
-
|
|
1337
|
+
const notesPart = notes ? `
|
|
1338
|
+
set description of newEvent to "${notes.replace(/"/g, '\\"')}"` : "";
|
|
1339
|
+
const endPart = endDateScript ? `
|
|
1340
|
+
set end date of newEvent to endD` : "";
|
|
1341
|
+
try {
|
|
1342
|
+
await runAppleScript(`tell application "Calendar" to launch`);
|
|
1343
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1344
|
+
} catch {
|
|
1345
|
+
}
|
|
954
1346
|
await runAppleScript(`
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
return {
|
|
1347
|
+
${startDateScript}
|
|
1348
|
+
${endDateScript}
|
|
1349
|
+
tell application "Calendar"
|
|
1350
|
+
tell ${calTarget}
|
|
1351
|
+
set newEvent to make new event with properties {summary:"${summary.replace(/"/g, '\\"')}", start date:startD}${endPart}${notesPart}
|
|
1352
|
+
end tell
|
|
1353
|
+
end tell`);
|
|
1354
|
+
return {
|
|
1355
|
+
success: true,
|
|
1356
|
+
data: { created: summary, start: startStr, end: endStr || "1 hour" }
|
|
1357
|
+
};
|
|
963
1358
|
}
|
|
964
1359
|
// ── Reminders ───────────────────────────────────────────
|
|
965
1360
|
case "sys_reminder_list": {
|
|
@@ -983,39 +1378,91 @@ print("\\(x),\\(y)")`;
|
|
|
983
1378
|
const raw = await runAppleScript(script);
|
|
984
1379
|
const reminders = raw.split("\n").filter(Boolean).map((line) => {
|
|
985
1380
|
const parts = line.split(" | ");
|
|
986
|
-
return parts.length === 3 ? {
|
|
1381
|
+
return parts.length === 3 ? {
|
|
1382
|
+
list: parts[0]?.trim(),
|
|
1383
|
+
name: parts[1]?.trim(),
|
|
1384
|
+
due: parts[2]?.trim()
|
|
1385
|
+
} : { name: parts[0]?.trim(), due: parts[1]?.trim() };
|
|
987
1386
|
});
|
|
988
|
-
return {
|
|
1387
|
+
return {
|
|
1388
|
+
success: true,
|
|
1389
|
+
data: { reminders, count: reminders.length }
|
|
1390
|
+
};
|
|
989
1391
|
} catch {
|
|
990
|
-
return {
|
|
1392
|
+
return {
|
|
1393
|
+
success: true,
|
|
1394
|
+
data: {
|
|
1395
|
+
reminders: [],
|
|
1396
|
+
count: 0,
|
|
1397
|
+
note: "No reminders or Reminders app not accessible"
|
|
1398
|
+
}
|
|
1399
|
+
};
|
|
991
1400
|
}
|
|
992
1401
|
}
|
|
993
1402
|
case "sys_reminder_create": {
|
|
994
1403
|
const reminderName = params.name;
|
|
995
1404
|
const dueDate = params.due;
|
|
996
|
-
|
|
997
|
-
if (!reminderName)
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1405
|
+
let listName2 = params.list || "";
|
|
1406
|
+
if (!reminderName)
|
|
1407
|
+
return { success: false, error: "Missing reminder name" };
|
|
1408
|
+
if (!listName2) {
|
|
1409
|
+
try {
|
|
1410
|
+
listName2 = (await runAppleScript(
|
|
1411
|
+
`tell application "Reminders" to return name of default list`
|
|
1412
|
+
)).trim();
|
|
1413
|
+
} catch {
|
|
1414
|
+
listName2 = "Reminders";
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
let duePart = "";
|
|
1418
|
+
if (dueDate) {
|
|
1419
|
+
const d = new Date(dueDate);
|
|
1420
|
+
if (!isNaN(d.getTime())) {
|
|
1421
|
+
duePart = `
|
|
1422
|
+
set dueD to current date
|
|
1423
|
+
set year of dueD to ${d.getFullYear()}
|
|
1424
|
+
set month of dueD to ${d.getMonth() + 1}
|
|
1425
|
+
set day of dueD to ${d.getDate()}
|
|
1426
|
+
set hours of dueD to ${d.getHours()}
|
|
1427
|
+
set minutes of dueD to ${d.getMinutes()}
|
|
1428
|
+
set seconds of dueD to 0`;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
const dueProperty = duePart ? `, due date:dueD` : "";
|
|
1432
|
+
await runAppleScript(`${duePart}
|
|
1433
|
+
tell application "Reminders"
|
|
1434
|
+
tell list "${listName2.replace(/"/g, '\\"')}"
|
|
1435
|
+
make new reminder with properties {name:"${reminderName.replace(/"/g, '\\"')}"${dueProperty}}
|
|
1436
|
+
end tell
|
|
1437
|
+
end tell`);
|
|
1438
|
+
return {
|
|
1439
|
+
success: true,
|
|
1440
|
+
data: {
|
|
1441
|
+
created: reminderName,
|
|
1442
|
+
due: dueDate || "none",
|
|
1443
|
+
list: listName2
|
|
1444
|
+
}
|
|
1445
|
+
};
|
|
1006
1446
|
}
|
|
1007
1447
|
// ── iMessage ────────────────────────────────────────────
|
|
1008
1448
|
case "sys_imessage_send": {
|
|
1009
1449
|
const to2 = params.to;
|
|
1010
1450
|
const msg = params.message;
|
|
1011
|
-
if (!to2 || !msg)
|
|
1451
|
+
if (!to2 || !msg)
|
|
1452
|
+
return {
|
|
1453
|
+
success: false,
|
|
1454
|
+
error: "Missing 'to' (phone/email) or 'message'"
|
|
1455
|
+
};
|
|
1012
1456
|
await runAppleScript(`
|
|
1013
1457
|
tell application "Messages"
|
|
1014
1458
|
set targetService to 1st account whose service type = iMessage
|
|
1015
1459
|
set targetBuddy to participant "${to2.replace(/"/g, '\\"')}" of targetService
|
|
1016
1460
|
send "${msg.replace(/"/g, '\\"')}" to targetBuddy
|
|
1017
1461
|
end tell`);
|
|
1018
|
-
return {
|
|
1462
|
+
return {
|
|
1463
|
+
success: true,
|
|
1464
|
+
data: { sent: true, to: to2, message: msg.slice(0, 100) }
|
|
1465
|
+
};
|
|
1019
1466
|
}
|
|
1020
1467
|
// ── System Info ─────────────────────────────────────────
|
|
1021
1468
|
case "sys_system_info": {
|
|
@@ -1026,7 +1473,9 @@ print("\\(x),\\(y)")`;
|
|
|
1026
1473
|
info.battery = "N/A";
|
|
1027
1474
|
}
|
|
1028
1475
|
try {
|
|
1029
|
-
info.wifi = (await runShell(
|
|
1476
|
+
info.wifi = (await runShell(
|
|
1477
|
+
"networksetup -getairportnetwork en0 2>/dev/null | cut -d: -f2"
|
|
1478
|
+
)).trim();
|
|
1030
1479
|
} catch {
|
|
1031
1480
|
info.wifi = "N/A";
|
|
1032
1481
|
}
|
|
@@ -1041,7 +1490,9 @@ print("\\(x),\\(y)")`;
|
|
|
1041
1490
|
info.ip_public = "N/A";
|
|
1042
1491
|
}
|
|
1043
1492
|
try {
|
|
1044
|
-
info.disk = (await runShell(
|
|
1493
|
+
info.disk = (await runShell(
|
|
1494
|
+
`df -h / | tail -1 | awk '{print $3 " used / " $2 " total (" $5 " used)"}'`
|
|
1495
|
+
)).trim();
|
|
1045
1496
|
} catch {
|
|
1046
1497
|
info.disk = "N/A";
|
|
1047
1498
|
}
|
|
@@ -1080,7 +1531,9 @@ print("\\(x),\\(y)")`;
|
|
|
1080
1531
|
await runAppleScript(`set volume output volume ${vol}`);
|
|
1081
1532
|
return { success: true, data: { volume: vol } };
|
|
1082
1533
|
}
|
|
1083
|
-
const raw2 = await runAppleScript(
|
|
1534
|
+
const raw2 = await runAppleScript(
|
|
1535
|
+
"output volume of (get volume settings)"
|
|
1536
|
+
);
|
|
1084
1537
|
return { success: true, data: { volume: Number(raw2) || 0 } };
|
|
1085
1538
|
}
|
|
1086
1539
|
// ── Brightness ──────────────────────────────────────────
|
|
@@ -1088,14 +1541,27 @@ print("\\(x),\\(y)")`;
|
|
|
1088
1541
|
const level2 = params.level;
|
|
1089
1542
|
if (level2 !== void 0) {
|
|
1090
1543
|
const br = Math.max(0, Math.min(1, Number(level2)));
|
|
1091
|
-
await runShell(
|
|
1544
|
+
await runShell(
|
|
1545
|
+
`brightness ${br} 2>/dev/null || osascript -e 'tell application "System Events" to tell appearance preferences to set dark mode to ${br < 0.3}'`
|
|
1546
|
+
);
|
|
1092
1547
|
return { success: true, data: { brightness: br } };
|
|
1093
1548
|
}
|
|
1094
1549
|
try {
|
|
1095
|
-
const raw3 = await runShell(
|
|
1096
|
-
|
|
1550
|
+
const raw3 = await runShell(
|
|
1551
|
+
"brightness -l 2>/dev/null | grep brightness | head -1 | awk '{print $NF}'"
|
|
1552
|
+
);
|
|
1553
|
+
return {
|
|
1554
|
+
success: true,
|
|
1555
|
+
data: { brightness: parseFloat(raw3) || 0.5 }
|
|
1556
|
+
};
|
|
1097
1557
|
} catch {
|
|
1098
|
-
return {
|
|
1558
|
+
return {
|
|
1559
|
+
success: true,
|
|
1560
|
+
data: {
|
|
1561
|
+
brightness: "unknown",
|
|
1562
|
+
note: "Install 'brightness' via brew for control"
|
|
1563
|
+
}
|
|
1564
|
+
};
|
|
1099
1565
|
}
|
|
1100
1566
|
}
|
|
1101
1567
|
// ── Do Not Disturb ──────────────────────────────────────
|
|
@@ -1103,49 +1569,79 @@ print("\\(x),\\(y)")`;
|
|
|
1103
1569
|
const enabled = params.enabled;
|
|
1104
1570
|
if (enabled !== void 0) {
|
|
1105
1571
|
try {
|
|
1106
|
-
await runShell(
|
|
1107
|
-
|
|
1572
|
+
await runShell(
|
|
1573
|
+
`shortcuts run "Toggle Do Not Disturb" 2>/dev/null || osascript -e 'do shell script "defaults write com.apple.ncprefs dnd_prefs -data 0"'`
|
|
1574
|
+
);
|
|
1575
|
+
return {
|
|
1576
|
+
success: true,
|
|
1577
|
+
data: { dnd: enabled, note: "DND toggled" }
|
|
1578
|
+
};
|
|
1108
1579
|
} catch {
|
|
1109
|
-
return {
|
|
1580
|
+
return {
|
|
1581
|
+
success: true,
|
|
1582
|
+
data: {
|
|
1583
|
+
dnd: enabled,
|
|
1584
|
+
note: "Set DND manually in Control Center"
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1110
1587
|
}
|
|
1111
1588
|
}
|
|
1112
|
-
return {
|
|
1589
|
+
return {
|
|
1590
|
+
success: true,
|
|
1591
|
+
data: { note: "Pass enabled: true/false to toggle DND" }
|
|
1592
|
+
};
|
|
1113
1593
|
}
|
|
1114
1594
|
// ── File Management ─────────────────────────────────────
|
|
1115
1595
|
case "sys_file_list": {
|
|
1116
1596
|
const dirPath = params.path || "Desktop";
|
|
1117
1597
|
const fullDir = safePath(dirPath);
|
|
1118
|
-
if (!fullDir)
|
|
1119
|
-
|
|
1598
|
+
if (!fullDir)
|
|
1599
|
+
return { success: false, error: `Access denied: ${dirPath}` };
|
|
1600
|
+
if (!existsSync(fullDir))
|
|
1601
|
+
return { success: false, error: `Directory not found: ${dirPath}` };
|
|
1120
1602
|
const entries = readdirSync(fullDir).map((name) => {
|
|
1121
1603
|
try {
|
|
1122
1604
|
const st = statSync(join(fullDir, name));
|
|
1123
|
-
return {
|
|
1605
|
+
return {
|
|
1606
|
+
name,
|
|
1607
|
+
type: st.isDirectory() ? "dir" : "file",
|
|
1608
|
+
size: st.size,
|
|
1609
|
+
modified: st.mtime.toISOString()
|
|
1610
|
+
};
|
|
1124
1611
|
} catch {
|
|
1125
1612
|
return { name, type: "unknown", size: 0, modified: "" };
|
|
1126
1613
|
}
|
|
1127
1614
|
});
|
|
1128
|
-
return {
|
|
1615
|
+
return {
|
|
1616
|
+
success: true,
|
|
1617
|
+
data: { path: dirPath, entries, count: entries.length }
|
|
1618
|
+
};
|
|
1129
1619
|
}
|
|
1130
1620
|
case "sys_file_move": {
|
|
1131
1621
|
const src = params.from;
|
|
1132
1622
|
const dst = params.to;
|
|
1133
|
-
if (!src || !dst)
|
|
1623
|
+
if (!src || !dst)
|
|
1624
|
+
return { success: false, error: "Missing from/to paths" };
|
|
1134
1625
|
const fullSrc = safePath(src);
|
|
1135
1626
|
const fullDst = safePath(dst);
|
|
1136
|
-
if (!fullSrc || !fullDst)
|
|
1137
|
-
|
|
1627
|
+
if (!fullSrc || !fullDst)
|
|
1628
|
+
return { success: false, error: "Access denied" };
|
|
1629
|
+
if (!existsSync(fullSrc))
|
|
1630
|
+
return { success: false, error: `Source not found: ${src}` };
|
|
1138
1631
|
renameSync(fullSrc, fullDst);
|
|
1139
1632
|
return { success: true, data: { moved: src, to: dst } };
|
|
1140
1633
|
}
|
|
1141
1634
|
case "sys_file_copy": {
|
|
1142
1635
|
const src2 = params.from;
|
|
1143
1636
|
const dst2 = params.to;
|
|
1144
|
-
if (!src2 || !dst2)
|
|
1637
|
+
if (!src2 || !dst2)
|
|
1638
|
+
return { success: false, error: "Missing from/to paths" };
|
|
1145
1639
|
const fullSrc2 = safePath(src2);
|
|
1146
1640
|
const fullDst2 = safePath(dst2);
|
|
1147
|
-
if (!fullSrc2 || !fullDst2)
|
|
1148
|
-
|
|
1641
|
+
if (!fullSrc2 || !fullDst2)
|
|
1642
|
+
return { success: false, error: "Access denied" };
|
|
1643
|
+
if (!existsSync(fullSrc2))
|
|
1644
|
+
return { success: false, error: `Source not found: ${src2}` };
|
|
1149
1645
|
copyFileSync(fullSrc2, fullDst2);
|
|
1150
1646
|
return { success: true, data: { copied: src2, to: dst2 } };
|
|
1151
1647
|
}
|
|
@@ -1154,16 +1650,23 @@ print("\\(x),\\(y)")`;
|
|
|
1154
1650
|
if (!target) return { success: false, error: "Missing path" };
|
|
1155
1651
|
const fullTarget = safePath(target);
|
|
1156
1652
|
if (!fullTarget) return { success: false, error: "Access denied" };
|
|
1157
|
-
if (!existsSync(fullTarget))
|
|
1158
|
-
|
|
1159
|
-
|
|
1653
|
+
if (!existsSync(fullTarget))
|
|
1654
|
+
return { success: false, error: `Not found: ${target}` };
|
|
1655
|
+
await runShell(
|
|
1656
|
+
`osascript -e 'tell application "Finder" to delete POSIX file "${fullTarget}"'`
|
|
1657
|
+
);
|
|
1658
|
+
return {
|
|
1659
|
+
success: true,
|
|
1660
|
+
data: { deleted: target, method: "moved_to_trash" }
|
|
1661
|
+
};
|
|
1160
1662
|
}
|
|
1161
1663
|
case "sys_file_info": {
|
|
1162
1664
|
const fPath = params.path;
|
|
1163
1665
|
if (!fPath) return { success: false, error: "Missing path" };
|
|
1164
1666
|
const fullF = safePath(fPath);
|
|
1165
1667
|
if (!fullF) return { success: false, error: "Access denied" };
|
|
1166
|
-
if (!existsSync(fullF))
|
|
1668
|
+
if (!existsSync(fullF))
|
|
1669
|
+
return { success: false, error: `Not found: ${fPath}` };
|
|
1167
1670
|
const st = statSync(fullF);
|
|
1168
1671
|
return {
|
|
1169
1672
|
success: true,
|
|
@@ -1186,9 +1689,15 @@ print("\\(x),\\(y)")`;
|
|
|
1186
1689
|
if (!dlUrl) return { success: false, error: "Missing URL" };
|
|
1187
1690
|
const fullDl = safePath(dlDest);
|
|
1188
1691
|
if (!fullDl) return { success: false, error: "Access denied" };
|
|
1189
|
-
await runShell(
|
|
1692
|
+
await runShell(
|
|
1693
|
+
`curl -sL -o "${fullDl}" "${dlUrl.replace(/"/g, '\\"')}"`,
|
|
1694
|
+
6e4
|
|
1695
|
+
);
|
|
1190
1696
|
const size = existsSync(fullDl) ? statSync(fullDl).size : 0;
|
|
1191
|
-
return {
|
|
1697
|
+
return {
|
|
1698
|
+
success: true,
|
|
1699
|
+
data: { downloaded: dlUrl, saved: dlDest, size }
|
|
1700
|
+
};
|
|
1192
1701
|
}
|
|
1193
1702
|
// ── Window Management ───────────────────────────────────
|
|
1194
1703
|
case "sys_window_list": {
|
|
@@ -1205,7 +1714,12 @@ print("\\(x),\\(y)")`;
|
|
|
1205
1714
|
end tell`);
|
|
1206
1715
|
const windows = raw4.split("\n").filter(Boolean).map((line) => {
|
|
1207
1716
|
const [app, title, pos, sz] = line.split(" | ");
|
|
1208
|
-
return {
|
|
1717
|
+
return {
|
|
1718
|
+
app: app?.trim(),
|
|
1719
|
+
title: title?.trim(),
|
|
1720
|
+
position: pos?.trim(),
|
|
1721
|
+
size: sz?.trim()
|
|
1722
|
+
};
|
|
1209
1723
|
});
|
|
1210
1724
|
return { success: true, data: { windows, count: windows.length } };
|
|
1211
1725
|
}
|
|
@@ -1228,7 +1742,11 @@ print("\\(x),\\(y)")`;
|
|
|
1228
1742
|
if (!app2) return { success: false, error: "Missing app name" };
|
|
1229
1743
|
const posPart = x !== void 0 && y !== void 0 ? `set position of window 1 to {${x}, ${y}}` : "";
|
|
1230
1744
|
const sizePart = w !== void 0 && h !== void 0 ? `set size of window 1 to {${w}, ${h}}` : "";
|
|
1231
|
-
if (!posPart && !sizePart)
|
|
1745
|
+
if (!posPart && !sizePart)
|
|
1746
|
+
return {
|
|
1747
|
+
success: false,
|
|
1748
|
+
error: "Provide x,y for position and/or width,height for size"
|
|
1749
|
+
};
|
|
1232
1750
|
await runAppleScript(`
|
|
1233
1751
|
tell application "System Events"
|
|
1234
1752
|
tell process "${app2.replace(/"/g, '\\"')}"
|
|
@@ -1236,7 +1754,14 @@ print("\\(x),\\(y)")`;
|
|
|
1236
1754
|
${sizePart}
|
|
1237
1755
|
end tell
|
|
1238
1756
|
end tell`);
|
|
1239
|
-
return {
|
|
1757
|
+
return {
|
|
1758
|
+
success: true,
|
|
1759
|
+
data: {
|
|
1760
|
+
app: app2,
|
|
1761
|
+
position: posPart ? { x, y } : "unchanged",
|
|
1762
|
+
size: sizePart ? { width: w, height: h } : "unchanged"
|
|
1763
|
+
}
|
|
1764
|
+
};
|
|
1240
1765
|
}
|
|
1241
1766
|
// ── Apple Notes ─────────────────────────────────────────
|
|
1242
1767
|
case "sys_notes_list": {
|
|
@@ -1252,7 +1777,11 @@ print("\\(x),\\(y)")`;
|
|
|
1252
1777
|
end tell`);
|
|
1253
1778
|
const notes = raw5.split("\n").filter(Boolean).map((line) => {
|
|
1254
1779
|
const [id, name, date] = line.split(" | ");
|
|
1255
|
-
return {
|
|
1780
|
+
return {
|
|
1781
|
+
id: id?.trim(),
|
|
1782
|
+
name: name?.trim(),
|
|
1783
|
+
modified: date?.trim()
|
|
1784
|
+
};
|
|
1256
1785
|
});
|
|
1257
1786
|
return { success: true, data: { notes, count: notes.length } };
|
|
1258
1787
|
}
|
|
@@ -1292,9 +1821,16 @@ print("\\(x),\\(y)")`;
|
|
|
1292
1821
|
end tell`);
|
|
1293
1822
|
const contacts = raw6.split("\n").filter(Boolean).map((line) => {
|
|
1294
1823
|
const [name, email, phone] = line.split(" | ");
|
|
1295
|
-
return {
|
|
1824
|
+
return {
|
|
1825
|
+
name: name?.trim(),
|
|
1826
|
+
email: email?.trim(),
|
|
1827
|
+
phone: phone?.trim()
|
|
1828
|
+
};
|
|
1296
1829
|
});
|
|
1297
|
-
return {
|
|
1830
|
+
return {
|
|
1831
|
+
success: true,
|
|
1832
|
+
data: { contacts, count: contacts.length, query: query2 }
|
|
1833
|
+
};
|
|
1298
1834
|
}
|
|
1299
1835
|
// ── OCR (Vision framework) ──────────────────────────────
|
|
1300
1836
|
case "sys_ocr": {
|
|
@@ -1302,7 +1838,8 @@ print("\\(x),\\(y)")`;
|
|
|
1302
1838
|
if (!imgPath) return { success: false, error: "Missing image path" };
|
|
1303
1839
|
const fullImg = imgPath.startsWith("/tmp/") ? imgPath : safePath(imgPath);
|
|
1304
1840
|
if (!fullImg) return { success: false, error: "Access denied" };
|
|
1305
|
-
if (!existsSync(fullImg))
|
|
1841
|
+
if (!existsSync(fullImg))
|
|
1842
|
+
return { success: false, error: `Image not found: ${imgPath}` };
|
|
1306
1843
|
const swiftOcr = `
|
|
1307
1844
|
import Foundation
|
|
1308
1845
|
import Vision
|
|
@@ -1324,11 +1861,312 @@ let text = results.compactMap { $0.topCandidates(1).first?.string }.joined(separ
|
|
|
1324
1861
|
print(text)`;
|
|
1325
1862
|
try {
|
|
1326
1863
|
const ocrText = await runSwift(swiftOcr, 3e4);
|
|
1327
|
-
return {
|
|
1864
|
+
return {
|
|
1865
|
+
success: true,
|
|
1866
|
+
data: {
|
|
1867
|
+
text: ocrText.slice(0, 1e4),
|
|
1868
|
+
length: ocrText.length,
|
|
1869
|
+
path: imgPath
|
|
1870
|
+
}
|
|
1871
|
+
};
|
|
1328
1872
|
} catch (err) {
|
|
1329
|
-
return {
|
|
1873
|
+
return {
|
|
1874
|
+
success: false,
|
|
1875
|
+
error: `OCR failed: ${err.message}`
|
|
1876
|
+
};
|
|
1330
1877
|
}
|
|
1331
1878
|
}
|
|
1879
|
+
// ── Philips Hue (Local Bridge API) ─────────────────────
|
|
1880
|
+
// Commands are prefixed with sys_hue_ (mapped from hue_ in chat.ts → system_hue_ → sys_hue_)
|
|
1881
|
+
case "sys_hue_lights_on": {
|
|
1882
|
+
const light = params.light;
|
|
1883
|
+
if (!light)
|
|
1884
|
+
return { success: false, error: "Missing light ID or name" };
|
|
1885
|
+
const hueConfig = await getHueConfig();
|
|
1886
|
+
if (!hueConfig)
|
|
1887
|
+
return {
|
|
1888
|
+
success: false,
|
|
1889
|
+
error: "Philips Hue not configured. Set HUE_BRIDGE_IP and HUE_USERNAME environment variables, or configure in Pulso Settings."
|
|
1890
|
+
};
|
|
1891
|
+
const lightId = await resolveHueLight(hueConfig, light);
|
|
1892
|
+
if (!lightId)
|
|
1893
|
+
return {
|
|
1894
|
+
success: false,
|
|
1895
|
+
error: `Light '${light}' not found on Hue bridge`
|
|
1896
|
+
};
|
|
1897
|
+
const state = { on: true };
|
|
1898
|
+
if (params.brightness !== void 0)
|
|
1899
|
+
state.bri = Math.max(1, Math.min(254, Number(params.brightness)));
|
|
1900
|
+
if (params.color) {
|
|
1901
|
+
const rgb = parseColorCompanion(params.color);
|
|
1902
|
+
if (rgb) {
|
|
1903
|
+
const [x, y] = rgbToXyCompanion(...rgb);
|
|
1904
|
+
state.xy = [x, y];
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
const hueRes = await hueRequest(
|
|
1908
|
+
hueConfig,
|
|
1909
|
+
`lights/${lightId}/state`,
|
|
1910
|
+
"PUT",
|
|
1911
|
+
state
|
|
1912
|
+
);
|
|
1913
|
+
return {
|
|
1914
|
+
success: true,
|
|
1915
|
+
data: { light: lightId, action: "on", ...state, response: hueRes }
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
case "sys_hue_lights_off": {
|
|
1919
|
+
const light = params.light;
|
|
1920
|
+
if (!light)
|
|
1921
|
+
return { success: false, error: "Missing light ID or name" };
|
|
1922
|
+
const hueConfig = await getHueConfig();
|
|
1923
|
+
if (!hueConfig)
|
|
1924
|
+
return { success: false, error: "Philips Hue not configured." };
|
|
1925
|
+
const lightId = await resolveHueLight(hueConfig, light);
|
|
1926
|
+
if (!lightId)
|
|
1927
|
+
return { success: false, error: `Light '${light}' not found` };
|
|
1928
|
+
const hueRes = await hueRequest(
|
|
1929
|
+
hueConfig,
|
|
1930
|
+
`lights/${lightId}/state`,
|
|
1931
|
+
"PUT",
|
|
1932
|
+
{ on: false }
|
|
1933
|
+
);
|
|
1934
|
+
return {
|
|
1935
|
+
success: true,
|
|
1936
|
+
data: { light: lightId, action: "off", response: hueRes }
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
case "sys_hue_lights_color": {
|
|
1940
|
+
const light = params.light;
|
|
1941
|
+
const color = params.color;
|
|
1942
|
+
if (!light || !color)
|
|
1943
|
+
return { success: false, error: "Missing light or color" };
|
|
1944
|
+
const hueConfig = await getHueConfig();
|
|
1945
|
+
if (!hueConfig)
|
|
1946
|
+
return { success: false, error: "Philips Hue not configured." };
|
|
1947
|
+
const lightId = await resolveHueLight(hueConfig, light);
|
|
1948
|
+
if (!lightId)
|
|
1949
|
+
return { success: false, error: `Light '${light}' not found` };
|
|
1950
|
+
const rgb = parseColorCompanion(color);
|
|
1951
|
+
if (!rgb)
|
|
1952
|
+
return { success: false, error: `Unrecognized color: ${color}` };
|
|
1953
|
+
const [x, y] = rgbToXyCompanion(...rgb);
|
|
1954
|
+
const hueRes = await hueRequest(
|
|
1955
|
+
hueConfig,
|
|
1956
|
+
`lights/${lightId}/state`,
|
|
1957
|
+
"PUT",
|
|
1958
|
+
{ on: true, xy: [x, y] }
|
|
1959
|
+
);
|
|
1960
|
+
return {
|
|
1961
|
+
success: true,
|
|
1962
|
+
data: { light: lightId, color, xy: [x, y], response: hueRes }
|
|
1963
|
+
};
|
|
1964
|
+
}
|
|
1965
|
+
case "sys_hue_lights_brightness": {
|
|
1966
|
+
const light = params.light;
|
|
1967
|
+
const brightness = Number(params.brightness);
|
|
1968
|
+
if (!light || isNaN(brightness))
|
|
1969
|
+
return { success: false, error: "Missing light or brightness" };
|
|
1970
|
+
const hueConfig = await getHueConfig();
|
|
1971
|
+
if (!hueConfig)
|
|
1972
|
+
return { success: false, error: "Philips Hue not configured." };
|
|
1973
|
+
const lightId = await resolveHueLight(hueConfig, light);
|
|
1974
|
+
if (!lightId)
|
|
1975
|
+
return { success: false, error: `Light '${light}' not found` };
|
|
1976
|
+
const bri = Math.max(1, Math.min(254, brightness));
|
|
1977
|
+
const hueRes = await hueRequest(
|
|
1978
|
+
hueConfig,
|
|
1979
|
+
`lights/${lightId}/state`,
|
|
1980
|
+
"PUT",
|
|
1981
|
+
{ on: true, bri }
|
|
1982
|
+
);
|
|
1983
|
+
return {
|
|
1984
|
+
success: true,
|
|
1985
|
+
data: { light: lightId, brightness: bri, response: hueRes }
|
|
1986
|
+
};
|
|
1987
|
+
}
|
|
1988
|
+
case "sys_hue_lights_scene": {
|
|
1989
|
+
const scene = params.scene;
|
|
1990
|
+
const group = params.group || "0";
|
|
1991
|
+
if (!scene) return { success: false, error: "Missing scene name" };
|
|
1992
|
+
const hueConfig = await getHueConfig();
|
|
1993
|
+
if (!hueConfig)
|
|
1994
|
+
return { success: false, error: "Philips Hue not configured." };
|
|
1995
|
+
const scenes = await hueRequest(hueConfig, "scenes", "GET");
|
|
1996
|
+
let sceneId = null;
|
|
1997
|
+
for (const [id, s] of Object.entries(scenes)) {
|
|
1998
|
+
if (s.name?.toLowerCase() === scene.toLowerCase()) {
|
|
1999
|
+
sceneId = id;
|
|
2000
|
+
break;
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
if (!sceneId)
|
|
2004
|
+
return {
|
|
2005
|
+
success: false,
|
|
2006
|
+
error: `Scene '${scene}' not found. Available: ${Object.values(
|
|
2007
|
+
scenes
|
|
2008
|
+
).map((s) => s.name).join(", ")}`
|
|
2009
|
+
};
|
|
2010
|
+
const hueRes = await hueRequest(
|
|
2011
|
+
hueConfig,
|
|
2012
|
+
`groups/${group}/action`,
|
|
2013
|
+
"PUT",
|
|
2014
|
+
{ scene: sceneId }
|
|
2015
|
+
);
|
|
2016
|
+
return {
|
|
2017
|
+
success: true,
|
|
2018
|
+
data: { scene, sceneId, group, response: hueRes }
|
|
2019
|
+
};
|
|
2020
|
+
}
|
|
2021
|
+
case "sys_hue_lights_list": {
|
|
2022
|
+
const hueConfig = await getHueConfig();
|
|
2023
|
+
if (!hueConfig)
|
|
2024
|
+
return { success: false, error: "Philips Hue not configured." };
|
|
2025
|
+
const [lights, groups, scenes] = await Promise.all([
|
|
2026
|
+
hueRequest(hueConfig, "lights", "GET"),
|
|
2027
|
+
hueRequest(hueConfig, "groups", "GET"),
|
|
2028
|
+
hueRequest(hueConfig, "scenes", "GET")
|
|
2029
|
+
]);
|
|
2030
|
+
return {
|
|
2031
|
+
success: true,
|
|
2032
|
+
data: {
|
|
2033
|
+
lights: Object.entries(lights).map(([id, l]) => ({
|
|
2034
|
+
id,
|
|
2035
|
+
name: l.name,
|
|
2036
|
+
on: l.state?.on,
|
|
2037
|
+
brightness: l.state?.bri,
|
|
2038
|
+
type: l.type
|
|
2039
|
+
})),
|
|
2040
|
+
groups: Object.entries(groups).map(([id, g]) => ({
|
|
2041
|
+
id,
|
|
2042
|
+
name: g.name,
|
|
2043
|
+
type: g.type,
|
|
2044
|
+
lightCount: g.lights?.length
|
|
2045
|
+
})),
|
|
2046
|
+
scenes: Object.entries(scenes).map(([id, s]) => ({
|
|
2047
|
+
id,
|
|
2048
|
+
name: s.name,
|
|
2049
|
+
group: s.group
|
|
2050
|
+
}))
|
|
2051
|
+
}
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
// ── Sonos (Local HTTP API) ──────────────────────────────
|
|
2055
|
+
case "sys_sonos_play": {
|
|
2056
|
+
const room = params.room;
|
|
2057
|
+
if (!room) return { success: false, error: "Missing room name" };
|
|
2058
|
+
const sonosUrl = getSonosApiUrl();
|
|
2059
|
+
if (!sonosUrl)
|
|
2060
|
+
return {
|
|
2061
|
+
success: false,
|
|
2062
|
+
error: "Sonos not configured. Set SONOS_API_URL environment variable (e.g., http://localhost:5005)."
|
|
2063
|
+
};
|
|
2064
|
+
const res = await sonosRequest(
|
|
2065
|
+
sonosUrl,
|
|
2066
|
+
`${encodeURIComponent(room)}/play`
|
|
2067
|
+
);
|
|
2068
|
+
return { success: true, data: { room, action: "play", response: res } };
|
|
2069
|
+
}
|
|
2070
|
+
case "sys_sonos_pause": {
|
|
2071
|
+
const room = params.room;
|
|
2072
|
+
if (!room) return { success: false, error: "Missing room name" };
|
|
2073
|
+
const sonosUrl = getSonosApiUrl();
|
|
2074
|
+
if (!sonosUrl)
|
|
2075
|
+
return { success: false, error: "Sonos not configured." };
|
|
2076
|
+
const res = await sonosRequest(
|
|
2077
|
+
sonosUrl,
|
|
2078
|
+
`${encodeURIComponent(room)}/pause`
|
|
2079
|
+
);
|
|
2080
|
+
return {
|
|
2081
|
+
success: true,
|
|
2082
|
+
data: { room, action: "pause", response: res }
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
case "sys_sonos_volume": {
|
|
2086
|
+
const room = params.room;
|
|
2087
|
+
const level = Number(params.level);
|
|
2088
|
+
if (!room || isNaN(level))
|
|
2089
|
+
return { success: false, error: "Missing room or level" };
|
|
2090
|
+
const sonosUrl = getSonosApiUrl();
|
|
2091
|
+
if (!sonosUrl)
|
|
2092
|
+
return { success: false, error: "Sonos not configured." };
|
|
2093
|
+
const vol = Math.max(0, Math.min(100, level));
|
|
2094
|
+
const res = await sonosRequest(
|
|
2095
|
+
sonosUrl,
|
|
2096
|
+
`${encodeURIComponent(room)}/volume/${vol}`
|
|
2097
|
+
);
|
|
2098
|
+
return { success: true, data: { room, volume: vol, response: res } };
|
|
2099
|
+
}
|
|
2100
|
+
case "sys_sonos_play_uri": {
|
|
2101
|
+
const room = params.room;
|
|
2102
|
+
const uri = params.uri;
|
|
2103
|
+
if (!room || !uri)
|
|
2104
|
+
return { success: false, error: "Missing room or URI" };
|
|
2105
|
+
const sonosUrl = getSonosApiUrl();
|
|
2106
|
+
if (!sonosUrl)
|
|
2107
|
+
return { success: false, error: "Sonos not configured." };
|
|
2108
|
+
if (uri.startsWith("spotify:")) {
|
|
2109
|
+
const res2 = await sonosRequest(
|
|
2110
|
+
sonosUrl,
|
|
2111
|
+
`${encodeURIComponent(room)}/spotify/now/${encodeURIComponent(uri)}`
|
|
2112
|
+
);
|
|
2113
|
+
return {
|
|
2114
|
+
success: true,
|
|
2115
|
+
data: { room, uri, type: "spotify", response: res2 }
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
const res = await sonosRequest(
|
|
2119
|
+
sonosUrl,
|
|
2120
|
+
`${encodeURIComponent(room)}/setavtransporturi/${encodeURIComponent(uri)}`
|
|
2121
|
+
);
|
|
2122
|
+
return { success: true, data: { room, uri, response: res } };
|
|
2123
|
+
}
|
|
2124
|
+
case "sys_sonos_rooms": {
|
|
2125
|
+
const sonosUrl = getSonosApiUrl();
|
|
2126
|
+
if (!sonosUrl)
|
|
2127
|
+
return { success: false, error: "Sonos not configured." };
|
|
2128
|
+
const res = await sonosRequest(sonosUrl, "zones");
|
|
2129
|
+
return { success: true, data: res };
|
|
2130
|
+
}
|
|
2131
|
+
case "sys_sonos_next": {
|
|
2132
|
+
const room = params.room;
|
|
2133
|
+
if (!room) return { success: false, error: "Missing room name" };
|
|
2134
|
+
const sonosUrl = getSonosApiUrl();
|
|
2135
|
+
if (!sonosUrl)
|
|
2136
|
+
return { success: false, error: "Sonos not configured." };
|
|
2137
|
+
const res = await sonosRequest(
|
|
2138
|
+
sonosUrl,
|
|
2139
|
+
`${encodeURIComponent(room)}/next`
|
|
2140
|
+
);
|
|
2141
|
+
return { success: true, data: { room, action: "next", response: res } };
|
|
2142
|
+
}
|
|
2143
|
+
case "sys_sonos_previous": {
|
|
2144
|
+
const room = params.room;
|
|
2145
|
+
if (!room) return { success: false, error: "Missing room name" };
|
|
2146
|
+
const sonosUrl = getSonosApiUrl();
|
|
2147
|
+
if (!sonosUrl)
|
|
2148
|
+
return { success: false, error: "Sonos not configured." };
|
|
2149
|
+
const res = await sonosRequest(
|
|
2150
|
+
sonosUrl,
|
|
2151
|
+
`${encodeURIComponent(room)}/previous`
|
|
2152
|
+
);
|
|
2153
|
+
return {
|
|
2154
|
+
success: true,
|
|
2155
|
+
data: { room, action: "previous", response: res }
|
|
2156
|
+
};
|
|
2157
|
+
}
|
|
2158
|
+
case "sys_sonos_now_playing": {
|
|
2159
|
+
const room = params.room;
|
|
2160
|
+
if (!room) return { success: false, error: "Missing room name" };
|
|
2161
|
+
const sonosUrl = getSonosApiUrl();
|
|
2162
|
+
if (!sonosUrl)
|
|
2163
|
+
return { success: false, error: "Sonos not configured." };
|
|
2164
|
+
const res = await sonosRequest(
|
|
2165
|
+
sonosUrl,
|
|
2166
|
+
`${encodeURIComponent(room)}/state`
|
|
2167
|
+
);
|
|
2168
|
+
return { success: true, data: { room, ...res } };
|
|
2169
|
+
}
|
|
1332
2170
|
default:
|
|
1333
2171
|
return { success: false, error: `Unknown command: ${command}` };
|
|
1334
2172
|
}
|
|
@@ -1336,6 +2174,130 @@ print(text)`;
|
|
|
1336
2174
|
return { success: false, error: err.message };
|
|
1337
2175
|
}
|
|
1338
2176
|
}
|
|
2177
|
+
var HUE_CONFIG_FILE = join(HOME, ".pulso-hue-config.json");
|
|
2178
|
+
async function getHueConfig() {
|
|
2179
|
+
const ip = process.env.HUE_BRIDGE_IP;
|
|
2180
|
+
const user = process.env.HUE_USERNAME;
|
|
2181
|
+
if (ip && user) return { bridgeIp: ip, username: user };
|
|
2182
|
+
try {
|
|
2183
|
+
if (existsSync(HUE_CONFIG_FILE)) {
|
|
2184
|
+
const data = JSON.parse(readFileSync(HUE_CONFIG_FILE, "utf-8"));
|
|
2185
|
+
if (data.bridgeIp && data.username) return data;
|
|
2186
|
+
}
|
|
2187
|
+
} catch {
|
|
2188
|
+
}
|
|
2189
|
+
return null;
|
|
2190
|
+
}
|
|
2191
|
+
async function hueRequest(config, path, method, body) {
|
|
2192
|
+
const url = `http://${config.bridgeIp}/api/${config.username}/${path}`;
|
|
2193
|
+
const controller = new AbortController();
|
|
2194
|
+
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
2195
|
+
try {
|
|
2196
|
+
const opts = {
|
|
2197
|
+
method,
|
|
2198
|
+
signal: controller.signal,
|
|
2199
|
+
headers: { "Content-Type": "application/json" }
|
|
2200
|
+
};
|
|
2201
|
+
if (body && method !== "GET") opts.body = JSON.stringify(body);
|
|
2202
|
+
const res = await fetch(url, opts);
|
|
2203
|
+
clearTimeout(timeout);
|
|
2204
|
+
return await res.json();
|
|
2205
|
+
} catch (err) {
|
|
2206
|
+
clearTimeout(timeout);
|
|
2207
|
+
throw new Error(
|
|
2208
|
+
`Hue bridge unreachable at ${config.bridgeIp}: ${err.message}`
|
|
2209
|
+
);
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
var hueLightCache = null;
|
|
2213
|
+
var hueLightCacheTs = 0;
|
|
2214
|
+
async function resolveHueLight(config, lightRef) {
|
|
2215
|
+
if (/^\d+$/.test(lightRef)) return lightRef;
|
|
2216
|
+
if (!hueLightCache || Date.now() - hueLightCacheTs > 3e5) {
|
|
2217
|
+
try {
|
|
2218
|
+
const lights = await hueRequest(config, "lights", "GET");
|
|
2219
|
+
hueLightCache = /* @__PURE__ */ new Map();
|
|
2220
|
+
for (const [id, light] of Object.entries(lights)) {
|
|
2221
|
+
hueLightCache.set(light.name.toLowerCase(), id);
|
|
2222
|
+
}
|
|
2223
|
+
hueLightCacheTs = Date.now();
|
|
2224
|
+
} catch {
|
|
2225
|
+
return null;
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
return hueLightCache.get(lightRef.toLowerCase()) ?? null;
|
|
2229
|
+
}
|
|
2230
|
+
var CSS_COLORS_COMPANION = {
|
|
2231
|
+
red: [255, 0, 0],
|
|
2232
|
+
green: [0, 128, 0],
|
|
2233
|
+
blue: [0, 0, 255],
|
|
2234
|
+
yellow: [255, 255, 0],
|
|
2235
|
+
orange: [255, 165, 0],
|
|
2236
|
+
purple: [128, 0, 128],
|
|
2237
|
+
pink: [255, 192, 203],
|
|
2238
|
+
white: [255, 255, 255],
|
|
2239
|
+
cyan: [0, 255, 255],
|
|
2240
|
+
magenta: [255, 0, 255],
|
|
2241
|
+
lime: [0, 255, 0],
|
|
2242
|
+
teal: [0, 128, 128],
|
|
2243
|
+
coral: [255, 127, 80],
|
|
2244
|
+
salmon: [250, 128, 114],
|
|
2245
|
+
gold: [255, 215, 0],
|
|
2246
|
+
lavender: [230, 230, 250],
|
|
2247
|
+
turquoise: [64, 224, 208],
|
|
2248
|
+
warmwhite: [255, 200, 150],
|
|
2249
|
+
coolwhite: [200, 220, 255]
|
|
2250
|
+
};
|
|
2251
|
+
function parseColorCompanion(color) {
|
|
2252
|
+
if (color.startsWith("#")) {
|
|
2253
|
+
const hex = color.slice(1);
|
|
2254
|
+
if (hex.length === 6) {
|
|
2255
|
+
return [
|
|
2256
|
+
parseInt(hex.slice(0, 2), 16),
|
|
2257
|
+
parseInt(hex.slice(2, 4), 16),
|
|
2258
|
+
parseInt(hex.slice(4, 6), 16)
|
|
2259
|
+
];
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
return CSS_COLORS_COMPANION[color.toLowerCase()] ?? null;
|
|
2263
|
+
}
|
|
2264
|
+
function rgbToXyCompanion(r, g, b) {
|
|
2265
|
+
let rr = r / 255;
|
|
2266
|
+
let gg = g / 255;
|
|
2267
|
+
let bb = b / 255;
|
|
2268
|
+
rr = rr > 0.04045 ? Math.pow((rr + 0.055) / 1.055, 2.4) : rr / 12.92;
|
|
2269
|
+
gg = gg > 0.04045 ? Math.pow((gg + 0.055) / 1.055, 2.4) : gg / 12.92;
|
|
2270
|
+
bb = bb > 0.04045 ? Math.pow((bb + 0.055) / 1.055, 2.4) : bb / 12.92;
|
|
2271
|
+
const X = rr * 0.664511 + gg * 0.154324 + bb * 0.162028;
|
|
2272
|
+
const Y = rr * 0.283881 + gg * 0.668433 + bb * 0.047685;
|
|
2273
|
+
const Z = rr * 88e-6 + gg * 0.07231 + bb * 0.986039;
|
|
2274
|
+
const sum = X + Y + Z;
|
|
2275
|
+
if (sum === 0) return [0.3127, 0.329];
|
|
2276
|
+
return [X / sum, Y / sum];
|
|
2277
|
+
}
|
|
2278
|
+
function getSonosApiUrl() {
|
|
2279
|
+
return process.env.SONOS_API_URL || null;
|
|
2280
|
+
}
|
|
2281
|
+
async function sonosRequest(baseUrl, path) {
|
|
2282
|
+
const url = `${baseUrl.replace(/\/$/, "")}/${path}`;
|
|
2283
|
+
const controller = new AbortController();
|
|
2284
|
+
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
2285
|
+
try {
|
|
2286
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
2287
|
+
clearTimeout(timeout);
|
|
2288
|
+
const text = await res.text();
|
|
2289
|
+
try {
|
|
2290
|
+
return JSON.parse(text);
|
|
2291
|
+
} catch {
|
|
2292
|
+
return { raw: text };
|
|
2293
|
+
}
|
|
2294
|
+
} catch (err) {
|
|
2295
|
+
clearTimeout(timeout);
|
|
2296
|
+
throw new Error(
|
|
2297
|
+
`Sonos API unreachable at ${baseUrl}: ${err.message}`
|
|
2298
|
+
);
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
1339
2301
|
var ws = null;
|
|
1340
2302
|
var reconnectTimer = null;
|
|
1341
2303
|
var heartbeatTimer = null;
|
|
@@ -1348,12 +2310,16 @@ function connect() {
|
|
|
1348
2310
|
ws.on("open", () => {
|
|
1349
2311
|
reconnectAttempts = 0;
|
|
1350
2312
|
console.log("\u2705 Connected to Pulso!");
|
|
1351
|
-
console.log(
|
|
2313
|
+
console.log(
|
|
2314
|
+
`\u{1F5A5}\uFE0F Companion is active \u2014 ${ACCESS_LEVEL === "full" ? "full device access" : "sandboxed mode"}`
|
|
2315
|
+
);
|
|
1352
2316
|
console.log("");
|
|
1353
2317
|
console.log(" Available capabilities:");
|
|
1354
2318
|
console.log(" \u2022 Open apps & URLs");
|
|
1355
2319
|
console.log(" \u2022 Control Spotify & media");
|
|
1356
|
-
console.log(
|
|
2320
|
+
console.log(
|
|
2321
|
+
` \u2022 Read/write files ${ACCESS_LEVEL === "full" ? "(full device)" : "(Documents, Desktop, Downloads)"}`
|
|
2322
|
+
);
|
|
1357
2323
|
console.log(" \u2022 Clipboard access");
|
|
1358
2324
|
console.log(" \u2022 Screenshots");
|
|
1359
2325
|
console.log(" \u2022 Text-to-speech");
|
|
@@ -1362,8 +2328,12 @@ function connect() {
|
|
|
1362
2328
|
console.log(" \u2022 Terminal commands");
|
|
1363
2329
|
console.log(" \u2022 System notifications");
|
|
1364
2330
|
console.log(" \u2022 macOS Shortcuts");
|
|
2331
|
+
console.log(" \u2022 Smart Home: Philips Hue (if configured)");
|
|
2332
|
+
console.log(" \u2022 Smart Home: Sonos (if configured)");
|
|
1365
2333
|
console.log("");
|
|
1366
|
-
console.log(
|
|
2334
|
+
console.log(
|
|
2335
|
+
` Access: ${ACCESS_LEVEL === "full" ? "\u{1F513} Full (unrestricted)" : "\u{1F512} Sandboxed (safe dirs only)"}`
|
|
2336
|
+
);
|
|
1367
2337
|
console.log(" Waiting for commands from Pulso agent...");
|
|
1368
2338
|
ws.send(JSON.stringify({ type: "extension_ready" }));
|
|
1369
2339
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
@@ -1384,10 +2354,16 @@ function connect() {
|
|
|
1384
2354
|
return;
|
|
1385
2355
|
}
|
|
1386
2356
|
if (msg.id && msg.command) {
|
|
1387
|
-
console.log(
|
|
1388
|
-
|
|
2357
|
+
console.log(
|
|
2358
|
+
`
|
|
2359
|
+
\u26A1 Command: ${msg.command}`,
|
|
2360
|
+
msg.params ? JSON.stringify(msg.params).slice(0, 200) : ""
|
|
2361
|
+
);
|
|
1389
2362
|
const result = await handleCommand(msg.command, msg.params ?? {});
|
|
1390
|
-
console.log(
|
|
2363
|
+
console.log(
|
|
2364
|
+
` \u2192 ${result.success ? "\u2705" : "\u274C"}`,
|
|
2365
|
+
result.success ? JSON.stringify(result.data).slice(0, 200) : result.error
|
|
2366
|
+
);
|
|
1391
2367
|
ws.send(JSON.stringify({ id: msg.id, result }));
|
|
1392
2368
|
return;
|
|
1393
2369
|
}
|
|
@@ -1405,9 +2381,15 @@ function connect() {
|
|
|
1405
2381
|
heartbeatTimer = null;
|
|
1406
2382
|
}
|
|
1407
2383
|
if (reasonStr === "New connection from same user") {
|
|
1408
|
-
console.log(
|
|
1409
|
-
|
|
1410
|
-
|
|
2384
|
+
console.log(
|
|
2385
|
+
"\n\u26A0\uFE0F Another Pulso Companion instance is already connected."
|
|
2386
|
+
);
|
|
2387
|
+
console.log(
|
|
2388
|
+
" This instance will exit. Only one companion per account is supported."
|
|
2389
|
+
);
|
|
2390
|
+
console.log(
|
|
2391
|
+
" If this is unexpected, close other terminals running Pulso Companion.\n"
|
|
2392
|
+
);
|
|
1411
2393
|
process.exit(0);
|
|
1412
2394
|
return;
|
|
1413
2395
|
}
|
|
@@ -1420,29 +2402,209 @@ function connect() {
|
|
|
1420
2402
|
function scheduleReconnect() {
|
|
1421
2403
|
if (reconnectTimer) return;
|
|
1422
2404
|
reconnectAttempts++;
|
|
1423
|
-
const delay = Math.min(
|
|
1424
|
-
|
|
2405
|
+
const delay = Math.min(
|
|
2406
|
+
RECONNECT_DELAY * Math.pow(2, reconnectAttempts - 1),
|
|
2407
|
+
6e4
|
|
2408
|
+
);
|
|
2409
|
+
console.log(
|
|
2410
|
+
` Reconnecting in ${(delay / 1e3).toFixed(0)}s... (attempt ${reconnectAttempts})`
|
|
2411
|
+
);
|
|
1425
2412
|
reconnectTimer = setTimeout(() => {
|
|
1426
2413
|
reconnectTimer = null;
|
|
1427
2414
|
connect();
|
|
1428
2415
|
}, delay);
|
|
1429
2416
|
}
|
|
2417
|
+
var wakeWordActive = false;
|
|
2418
|
+
async function startWakeWordDetection() {
|
|
2419
|
+
if (!WAKE_WORD_ENABLED) return;
|
|
2420
|
+
if (!PICOVOICE_ACCESS_KEY) {
|
|
2421
|
+
console.log(" \u26A0\uFE0F Wake word enabled but no Picovoice key provided.");
|
|
2422
|
+
console.log(" Get a free key at https://console.picovoice.ai/");
|
|
2423
|
+
console.log(
|
|
2424
|
+
" Use: --picovoice-key <key> or PICOVOICE_ACCESS_KEY env var\n"
|
|
2425
|
+
);
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
try {
|
|
2429
|
+
const { Porcupine, BuiltinKeyword } = await import("@picovoice/porcupine-node");
|
|
2430
|
+
const { PvRecorder } = await import("@picovoice/pvrecorder-node");
|
|
2431
|
+
const customKeywordPath = join(HOME, ".pulso-wake-word.ppn");
|
|
2432
|
+
const useCustom = existsSync(customKeywordPath);
|
|
2433
|
+
let porcupine;
|
|
2434
|
+
if (useCustom) {
|
|
2435
|
+
porcupine = new Porcupine(
|
|
2436
|
+
PICOVOICE_ACCESS_KEY,
|
|
2437
|
+
[customKeywordPath],
|
|
2438
|
+
[0.5]
|
|
2439
|
+
// sensitivity
|
|
2440
|
+
);
|
|
2441
|
+
console.log(
|
|
2442
|
+
" \u{1F3A4} Wake word: custom model loaded from ~/.pulso-wake-word.ppn"
|
|
2443
|
+
);
|
|
2444
|
+
} else {
|
|
2445
|
+
porcupine = new Porcupine(
|
|
2446
|
+
PICOVOICE_ACCESS_KEY,
|
|
2447
|
+
[BuiltinKeyword.HEY_GOOGLE],
|
|
2448
|
+
// Placeholder — replace with custom "Hey Pulso"
|
|
2449
|
+
[0.5]
|
|
2450
|
+
);
|
|
2451
|
+
console.log(
|
|
2452
|
+
' \u{1F3A4} Wake word: using "Hey Google" as placeholder (create custom "Hey Pulso" at console.picovoice.ai)'
|
|
2453
|
+
);
|
|
2454
|
+
}
|
|
2455
|
+
const frameLength = porcupine.frameLength;
|
|
2456
|
+
const sampleRate = porcupine.sampleRate;
|
|
2457
|
+
const devices = PvRecorder.getAvailableDevices();
|
|
2458
|
+
console.log(` \u{1F3A4} Audio devices: ${devices.length} found (using default)`);
|
|
2459
|
+
const recorder = new PvRecorder(frameLength, 0);
|
|
2460
|
+
recorder.start();
|
|
2461
|
+
wakeWordActive = true;
|
|
2462
|
+
console.log(" \u{1F3A4} Wake word detection ACTIVE \u2014 listening...\n");
|
|
2463
|
+
const listen = async () => {
|
|
2464
|
+
while (wakeWordActive) {
|
|
2465
|
+
try {
|
|
2466
|
+
const pcm = await recorder.read();
|
|
2467
|
+
const keywordIndex = porcupine.process(pcm);
|
|
2468
|
+
if (keywordIndex >= 0) {
|
|
2469
|
+
console.log("\n \u{1F5E3}\uFE0F Wake word detected!");
|
|
2470
|
+
exec("afplay /System/Library/Sounds/Tink.aiff");
|
|
2471
|
+
const audioChunks = [];
|
|
2472
|
+
let silenceFrames = 0;
|
|
2473
|
+
const MAX_SILENCE_FRAMES = Math.ceil(
|
|
2474
|
+
1.5 * sampleRate / frameLength
|
|
2475
|
+
);
|
|
2476
|
+
const MAX_RECORD_FRAMES = Math.ceil(
|
|
2477
|
+
10 * sampleRate / frameLength
|
|
2478
|
+
);
|
|
2479
|
+
let totalFrames = 0;
|
|
2480
|
+
console.log(" \u{1F3A4} Listening for command...");
|
|
2481
|
+
while (totalFrames < MAX_RECORD_FRAMES) {
|
|
2482
|
+
const frame = await recorder.read();
|
|
2483
|
+
audioChunks.push(new Int16Array(frame));
|
|
2484
|
+
totalFrames++;
|
|
2485
|
+
let sum = 0;
|
|
2486
|
+
for (let i = 0; i < frame.length; i++) {
|
|
2487
|
+
sum += frame[i] * frame[i];
|
|
2488
|
+
}
|
|
2489
|
+
const rms = Math.sqrt(sum / frame.length);
|
|
2490
|
+
if (rms < 200) {
|
|
2491
|
+
silenceFrames++;
|
|
2492
|
+
if (silenceFrames >= MAX_SILENCE_FRAMES && totalFrames > 5) {
|
|
2493
|
+
break;
|
|
2494
|
+
}
|
|
2495
|
+
} else {
|
|
2496
|
+
silenceFrames = 0;
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
console.log(
|
|
2500
|
+
` \u{1F3A4} Captured ${(totalFrames * frameLength / sampleRate).toFixed(1)}s of audio`
|
|
2501
|
+
);
|
|
2502
|
+
const totalSamples = audioChunks.reduce((s, c) => s + c.length, 0);
|
|
2503
|
+
const wavBuffer = createWavBuffer(
|
|
2504
|
+
audioChunks,
|
|
2505
|
+
sampleRate,
|
|
2506
|
+
totalSamples
|
|
2507
|
+
);
|
|
2508
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
2509
|
+
const base64Audio = Buffer.from(wavBuffer).toString("base64");
|
|
2510
|
+
ws.send(
|
|
2511
|
+
JSON.stringify({
|
|
2512
|
+
type: "wake_word_audio",
|
|
2513
|
+
audio: base64Audio,
|
|
2514
|
+
format: "wav",
|
|
2515
|
+
sampleRate,
|
|
2516
|
+
durationMs: Math.round(
|
|
2517
|
+
totalFrames * frameLength / sampleRate * 1e3
|
|
2518
|
+
)
|
|
2519
|
+
})
|
|
2520
|
+
);
|
|
2521
|
+
console.log(" \u{1F4E4} Audio sent to server for processing");
|
|
2522
|
+
exec("afplay /System/Library/Sounds/Pop.aiff");
|
|
2523
|
+
} else {
|
|
2524
|
+
console.log(" \u26A0\uFE0F Not connected to server \u2014 audio discarded");
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
} catch (err) {
|
|
2528
|
+
if (wakeWordActive) {
|
|
2529
|
+
console.error(" \u274C Wake word error:", err.message);
|
|
2530
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
recorder.stop();
|
|
2535
|
+
recorder.release();
|
|
2536
|
+
porcupine.release();
|
|
2537
|
+
};
|
|
2538
|
+
listen().catch((err) => {
|
|
2539
|
+
console.error(" \u274C Wake word listener crashed:", err.message);
|
|
2540
|
+
wakeWordActive = false;
|
|
2541
|
+
});
|
|
2542
|
+
} catch (err) {
|
|
2543
|
+
const msg = err.message;
|
|
2544
|
+
if (msg.includes("Cannot find module") || msg.includes("MODULE_NOT_FOUND")) {
|
|
2545
|
+
console.log(" \u26A0\uFE0F Wake word packages not installed. Run:");
|
|
2546
|
+
console.log(
|
|
2547
|
+
" npm install @picovoice/porcupine-node @picovoice/pvrecorder-node\n"
|
|
2548
|
+
);
|
|
2549
|
+
} else {
|
|
2550
|
+
console.error(" \u274C Wake word init failed:", msg);
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
function createWavBuffer(chunks, sampleRate, totalSamples) {
|
|
2555
|
+
const bytesPerSample = 2;
|
|
2556
|
+
const numChannels = 1;
|
|
2557
|
+
const dataSize = totalSamples * bytesPerSample;
|
|
2558
|
+
const buffer = new ArrayBuffer(44 + dataSize);
|
|
2559
|
+
const view = new DataView(buffer);
|
|
2560
|
+
writeString(view, 0, "RIFF");
|
|
2561
|
+
view.setUint32(4, 36 + dataSize, true);
|
|
2562
|
+
writeString(view, 8, "WAVE");
|
|
2563
|
+
writeString(view, 12, "fmt ");
|
|
2564
|
+
view.setUint32(16, 16, true);
|
|
2565
|
+
view.setUint16(20, 1, true);
|
|
2566
|
+
view.setUint16(22, numChannels, true);
|
|
2567
|
+
view.setUint32(24, sampleRate, true);
|
|
2568
|
+
view.setUint32(28, sampleRate * numChannels * bytesPerSample, true);
|
|
2569
|
+
view.setUint16(32, numChannels * bytesPerSample, true);
|
|
2570
|
+
view.setUint16(34, bytesPerSample * 8, true);
|
|
2571
|
+
writeString(view, 36, "data");
|
|
2572
|
+
view.setUint32(40, dataSize, true);
|
|
2573
|
+
let offset = 44;
|
|
2574
|
+
for (const chunk of chunks) {
|
|
2575
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
2576
|
+
view.setInt16(offset, chunk[i], true);
|
|
2577
|
+
offset += 2;
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
return buffer;
|
|
2581
|
+
}
|
|
2582
|
+
function writeString(view, offset, str) {
|
|
2583
|
+
for (let i = 0; i < str.length; i++) {
|
|
2584
|
+
view.setUint8(offset + i, str.charCodeAt(i));
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
1430
2587
|
console.log("");
|
|
1431
2588
|
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.
|
|
2589
|
+
console.log(" \u2551 \u{1FAC0} Pulso Mac Companion v0.2.3 \u2551");
|
|
1433
2590
|
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
2591
|
console.log("");
|
|
1435
2592
|
setupPermissions().then(() => {
|
|
1436
2593
|
connect();
|
|
2594
|
+
if (WAKE_WORD_ENABLED) {
|
|
2595
|
+
startWakeWordDetection();
|
|
2596
|
+
}
|
|
1437
2597
|
}).catch(() => {
|
|
1438
2598
|
connect();
|
|
1439
2599
|
});
|
|
1440
2600
|
process.on("SIGINT", () => {
|
|
1441
2601
|
console.log("\n\u{1F44B} Shutting down Pulso Companion...");
|
|
2602
|
+
wakeWordActive = false;
|
|
1442
2603
|
ws?.close(1e3, "User shutdown");
|
|
1443
2604
|
process.exit(0);
|
|
1444
2605
|
});
|
|
1445
2606
|
process.on("SIGTERM", () => {
|
|
2607
|
+
wakeWordActive = false;
|
|
1446
2608
|
ws?.close(1e3, "Process terminated");
|
|
1447
2609
|
process.exit(0);
|
|
1448
2610
|
});
|