@vox-ai-app/integrations 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -0
- package/package.json +42 -0
- package/src/imessage/def.js +41 -0
- package/src/imessage/index.js +9 -0
- package/src/imessage/mac/data.js +144 -0
- package/src/imessage/mac/reply.js +68 -0
- package/src/imessage/mac/service.js +141 -0
- package/src/imessage/tools.js +44 -0
- package/src/index.js +7 -0
- package/src/mail/def.js +317 -0
- package/src/mail/index.js +165 -0
- package/src/mail/manage/index.js +10 -0
- package/src/mail/manage/mac/index.js +275 -0
- package/src/mail/read/index.js +1 -0
- package/src/mail/read/mac/accounts.js +53 -0
- package/src/mail/read/mac/index.js +170 -0
- package/src/mail/read/mac/permission.js +29 -0
- package/src/mail/read/mac/sync.js +98 -0
- package/src/mail/read/mac/transform.js +55 -0
- package/src/mail/send/index.js +1 -0
- package/src/mail/send/mac/index.js +93 -0
- package/src/mail/shared/index.js +6 -0
- package/src/mail/shared/mac/index.js +48 -0
- package/src/mail/tools.js +41 -0
- package/src/screen/capture/index.js +1 -0
- package/src/screen/capture/mac/index.js +109 -0
- package/src/screen/control/index.js +15 -0
- package/src/screen/control/mac/accessibility.js +25 -0
- package/src/screen/control/mac/apps.js +62 -0
- package/src/screen/control/mac/exec.js +66 -0
- package/src/screen/control/mac/helpers.js +5 -0
- package/src/screen/control/mac/index.js +10 -0
- package/src/screen/control/mac/keyboard.js +34 -0
- package/src/screen/control/mac/keycodes.js +87 -0
- package/src/screen/control/mac/mouse.js +59 -0
- package/src/screen/control/mac/python-keyboard.js +66 -0
- package/src/screen/control/mac/python-mouse.js +66 -0
- package/src/screen/control/mac/python.js +2 -0
- package/src/screen/control/mac/ui-scan.js +45 -0
- package/src/screen/def.js +304 -0
- package/src/screen/index.js +17 -0
- package/src/screen/queue.js +54 -0
- package/src/screen/tools.js +50 -0
- package/src/tools.js +6 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { resolveLocalPath } from '@vox-ai-app/tools'
|
|
2
|
+
import {
|
|
3
|
+
execAbortable,
|
|
4
|
+
esc,
|
|
5
|
+
EXEC_TIMEOUT,
|
|
6
|
+
writeTempScript,
|
|
7
|
+
cleanupTemp,
|
|
8
|
+
parseTabSeparated
|
|
9
|
+
} from '@vox-ai-app/tools/exec'
|
|
10
|
+
import { ensureAppleMailConfigured } from '../../shared/index.js'
|
|
11
|
+
export const sendEmailMac = async (
|
|
12
|
+
{ to, cc, bcc, subject, body, attachments, account },
|
|
13
|
+
{ signal } = {}
|
|
14
|
+
) => {
|
|
15
|
+
await ensureAppleMailConfigured(signal)
|
|
16
|
+
const bodyEsc = esc(body).replace(/\n/g, '\\n')
|
|
17
|
+
const lines = ['tell application "Mail"']
|
|
18
|
+
if (account) {
|
|
19
|
+
lines.push(
|
|
20
|
+
` set acct to first account whose name contains "${esc(account)}"`,
|
|
21
|
+
' set addressesList to email addresses of acct',
|
|
22
|
+
' set senderAddr to item 1 of addressesList',
|
|
23
|
+
` set msg to make new outgoing message with properties {subject:"${esc(subject)}", content:"${bodyEsc}", visible:false, sender:senderAddr}`
|
|
24
|
+
)
|
|
25
|
+
} else {
|
|
26
|
+
lines.push(
|
|
27
|
+
` set msg to make new outgoing message with properties {subject:"${esc(subject)}", content:"${bodyEsc}", visible:false}`
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
lines.push(' tell msg')
|
|
31
|
+
for (const addr of to) {
|
|
32
|
+
lines.push(` make new to recipient with properties {address:"${esc(addr)}"}`)
|
|
33
|
+
}
|
|
34
|
+
for (const addr of cc) {
|
|
35
|
+
lines.push(` make new cc recipient with properties {address:"${esc(addr)}"}`)
|
|
36
|
+
}
|
|
37
|
+
for (const addr of bcc) {
|
|
38
|
+
lines.push(` make new bcc recipient with properties {address:"${esc(addr)}"}`)
|
|
39
|
+
}
|
|
40
|
+
for (const p of attachments) {
|
|
41
|
+
const abs = resolveLocalPath(p)
|
|
42
|
+
lines.push(
|
|
43
|
+
` make new attachment with properties {file name:(POSIX file "${esc(abs)}")} at after the last paragraph`
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
lines.push(' end tell', ' send msg', 'end tell')
|
|
47
|
+
const scriptFile = await writeTempScript(lines.join('\n'), 'scpt')
|
|
48
|
+
try {
|
|
49
|
+
await execAbortable(
|
|
50
|
+
`osascript "${scriptFile}"`,
|
|
51
|
+
{
|
|
52
|
+
timeout: EXEC_TIMEOUT
|
|
53
|
+
},
|
|
54
|
+
signal
|
|
55
|
+
)
|
|
56
|
+
return {
|
|
57
|
+
status: 'sent',
|
|
58
|
+
to,
|
|
59
|
+
subject
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
await cleanupTemp(scriptFile)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export const searchContactsMac = async (query, { signal } = {}) => {
|
|
66
|
+
const script = [
|
|
67
|
+
`set Q to "${esc(query)}"`,
|
|
68
|
+
'set output to ""',
|
|
69
|
+
'tell application "Contacts"',
|
|
70
|
+
' set matched to every person whose name contains Q',
|
|
71
|
+
' repeat with p in matched',
|
|
72
|
+
' set pName to name of p',
|
|
73
|
+
' repeat with e in emails of p',
|
|
74
|
+
' set output to output & pName & "\\t" & (value of e) & "\\n"',
|
|
75
|
+
' end repeat',
|
|
76
|
+
' end repeat',
|
|
77
|
+
'end tell',
|
|
78
|
+
'return output'
|
|
79
|
+
].join('\n')
|
|
80
|
+
const scriptFile = await writeTempScript(script, 'scpt')
|
|
81
|
+
try {
|
|
82
|
+
const { stdout } = await execAbortable(
|
|
83
|
+
`osascript "${scriptFile}"`,
|
|
84
|
+
{
|
|
85
|
+
timeout: EXEC_TIMEOUT
|
|
86
|
+
},
|
|
87
|
+
signal
|
|
88
|
+
)
|
|
89
|
+
return parseTabSeparated(stdout)
|
|
90
|
+
} finally {
|
|
91
|
+
await cleanupTemp(scriptFile)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { shell } from 'electron'
|
|
2
|
+
import { execAbortable } from '@vox-ai-app/tools/exec'
|
|
3
|
+
const AUTOMATION_PERMISSION_MESSAGE =
|
|
4
|
+
'Vox needs permission to control Mail. Please grant it in System Settings → Privacy & Security → Automation → Vox → Mail.'
|
|
5
|
+
const APPLE_MAIL_SETUP_MESSAGE =
|
|
6
|
+
'macOS mail actions in Vox require Apple Mail with at least one configured account. If you use Gmail or Outlook elsewhere, add the account in Apple Mail first. If Mail is already set up, check macOS Automation permissions for Vox and try again.'
|
|
7
|
+
const isAutomationDeniedError = (err) => {
|
|
8
|
+
const msg = String(err?.message || err?.stderr || '').toLowerCase()
|
|
9
|
+
return (
|
|
10
|
+
msg.includes('not allowed to send apple events') ||
|
|
11
|
+
msg.includes('apple event handler failed') ||
|
|
12
|
+
msg.includes('-1743') ||
|
|
13
|
+
msg.includes('access not allowed')
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
export const openMailAutomationSettings = () => {
|
|
17
|
+
shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_Automation')
|
|
18
|
+
}
|
|
19
|
+
export const getAppleMailAccounts = async (signal) => {
|
|
20
|
+
try {
|
|
21
|
+
const { stdout } = await execAbortable(
|
|
22
|
+
'osascript -e \'tell application "Mail" to return name of every account\'',
|
|
23
|
+
{
|
|
24
|
+
timeout: 15_000
|
|
25
|
+
},
|
|
26
|
+
signal
|
|
27
|
+
)
|
|
28
|
+
return String(stdout)
|
|
29
|
+
.trim()
|
|
30
|
+
.split(',')
|
|
31
|
+
.map((name) => name.trim())
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (isAutomationDeniedError(err)) {
|
|
35
|
+
openMailAutomationSettings()
|
|
36
|
+
throw Object.assign(new Error(AUTOMATION_PERMISSION_MESSAGE), {
|
|
37
|
+
code: 'MAIL_AUTOMATION_REQUIRED'
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
throw new Error(APPLE_MAIL_SETUP_MESSAGE)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export const ensureAppleMailConfigured = async (signal) => {
|
|
44
|
+
const accounts = await getAppleMailAccounts(signal)
|
|
45
|
+
if (!accounts.length) throw new Error(APPLE_MAIL_SETUP_MESSAGE)
|
|
46
|
+
return accounts
|
|
47
|
+
}
|
|
48
|
+
export { APPLE_MAIL_SETUP_MESSAGE }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { MAIL_TOOL_DEFINITIONS } from './def.js'
|
|
2
|
+
import {
|
|
3
|
+
sendEmail,
|
|
4
|
+
readEmails,
|
|
5
|
+
searchContacts,
|
|
6
|
+
getEmailBody,
|
|
7
|
+
replyToEmail,
|
|
8
|
+
forwardEmail,
|
|
9
|
+
markEmailRead,
|
|
10
|
+
flagEmail,
|
|
11
|
+
deleteEmail,
|
|
12
|
+
moveEmail,
|
|
13
|
+
createDraft,
|
|
14
|
+
saveAttachment
|
|
15
|
+
} from './index.js'
|
|
16
|
+
|
|
17
|
+
const DARWIN_ONLY = () => {
|
|
18
|
+
throw new Error('Mail tools are only available on macOS.')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const isDarwin = process.platform === 'darwin'
|
|
22
|
+
|
|
23
|
+
const executors = {
|
|
24
|
+
read_emails: (_ctx) => (isDarwin ? readEmails : DARWIN_ONLY),
|
|
25
|
+
search_contacts: (_ctx) => (isDarwin ? searchContacts : DARWIN_ONLY),
|
|
26
|
+
send_email: (_ctx) => (isDarwin ? sendEmail : DARWIN_ONLY),
|
|
27
|
+
get_email_body: (_ctx) => (isDarwin ? getEmailBody : DARWIN_ONLY),
|
|
28
|
+
reply_to_email: (_ctx) => (isDarwin ? replyToEmail : DARWIN_ONLY),
|
|
29
|
+
forward_email: (_ctx) => (isDarwin ? forwardEmail : DARWIN_ONLY),
|
|
30
|
+
mark_email_read: (_ctx) => (isDarwin ? markEmailRead : DARWIN_ONLY),
|
|
31
|
+
flag_email: (_ctx) => (isDarwin ? flagEmail : DARWIN_ONLY),
|
|
32
|
+
delete_email: (_ctx) => (isDarwin ? deleteEmail : DARWIN_ONLY),
|
|
33
|
+
move_email: (_ctx) => (isDarwin ? moveEmail : DARWIN_ONLY),
|
|
34
|
+
create_draft: (_ctx) => (isDarwin ? createDraft : DARWIN_ONLY),
|
|
35
|
+
save_attachment: (_ctx) => (isDarwin ? saveAttachment : DARWIN_ONLY)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const MAIL_TOOLS = MAIL_TOOL_DEFINITIONS.map((def) => ({
|
|
39
|
+
definition: def,
|
|
40
|
+
execute: executors[def.name]
|
|
41
|
+
}))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { captureFullScreen, captureRegion, waitForScreenPermission } from './mac/index.js'
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { desktopCapturer, screen, shell, systemPreferences } from 'electron'
|
|
2
|
+
import { exec } from 'child_process'
|
|
3
|
+
import fs from 'fs/promises'
|
|
4
|
+
import os from 'os'
|
|
5
|
+
import path from 'path'
|
|
6
|
+
const execAbortable = (command, options = {}, signal) => {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
if (signal?.aborted) {
|
|
9
|
+
reject(new Error('Aborted'))
|
|
10
|
+
return
|
|
11
|
+
}
|
|
12
|
+
const child = exec(command, options, (error, stdout, stderr) => {
|
|
13
|
+
if (signal) signal.removeEventListener('abort', onAbort)
|
|
14
|
+
if (error)
|
|
15
|
+
reject(
|
|
16
|
+
Object.assign(error, {
|
|
17
|
+
stderr
|
|
18
|
+
})
|
|
19
|
+
)
|
|
20
|
+
else
|
|
21
|
+
resolve({
|
|
22
|
+
stdout,
|
|
23
|
+
stderr
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
const onAbort = () => {
|
|
27
|
+
try {
|
|
28
|
+
child.kill('SIGTERM')
|
|
29
|
+
} catch {
|
|
30
|
+
void 0
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (signal)
|
|
34
|
+
signal.addEventListener('abort', onAbort, {
|
|
35
|
+
once: true
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
export const waitForScreenPermission = async (signal) => {
|
|
40
|
+
const initial = systemPreferences.getMediaAccessStatus('screen')
|
|
41
|
+
if (initial === 'granted') return
|
|
42
|
+
await desktopCapturer
|
|
43
|
+
.getSources({
|
|
44
|
+
types: ['screen'],
|
|
45
|
+
thumbnailSize: {
|
|
46
|
+
width: 1,
|
|
47
|
+
height: 1
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
.catch(() => {})
|
|
51
|
+
await shell.openExternal(
|
|
52
|
+
'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'
|
|
53
|
+
)
|
|
54
|
+
const deadline = Date.now() + 60_000
|
|
55
|
+
while (Date.now() < deadline) {
|
|
56
|
+
if (signal?.aborted) throw new Error('Aborted')
|
|
57
|
+
await new Promise((r) => setTimeout(r, 500))
|
|
58
|
+
if (systemPreferences.getMediaAccessStatus('screen') === 'granted') return
|
|
59
|
+
}
|
|
60
|
+
throw new Error(
|
|
61
|
+
'Screen recording permission was not granted. Please allow access in System Settings → Privacy & Security → Screen Recording.'
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
export const captureFullScreen = async (_, { signal } = {}) => {
|
|
65
|
+
await waitForScreenPermission(signal)
|
|
66
|
+
const primaryDisplay = screen.getPrimaryDisplay()
|
|
67
|
+
const { width, height } = primaryDisplay.size
|
|
68
|
+
const sources = await desktopCapturer.getSources({
|
|
69
|
+
types: ['screen'],
|
|
70
|
+
thumbnailSize: {
|
|
71
|
+
width,
|
|
72
|
+
height
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
const primary = sources[0]
|
|
76
|
+
if (!primary) throw new Error('No screen source available for capture.')
|
|
77
|
+
const captureSize = primary.thumbnail.getSize()
|
|
78
|
+
const base64Image = primary.thumbnail.toJPEG(75).toString('base64')
|
|
79
|
+
return {
|
|
80
|
+
text: `Captured full screen (${captureSize.width}x${captureSize.height}). Coordinates in this image map directly to click_at screen coordinates.`,
|
|
81
|
+
imageBase64: base64Image,
|
|
82
|
+
mimeType: 'image/jpeg'
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
export const captureRegion = async ({ x, y, width, height }, { signal } = {}) => {
|
|
86
|
+
await waitForScreenPermission(signal)
|
|
87
|
+
const xi = Math.round(Number(x))
|
|
88
|
+
const yi = Math.round(Number(y))
|
|
89
|
+
const wi = Math.round(Number(width))
|
|
90
|
+
const hi = Math.round(Number(height))
|
|
91
|
+
const tmpFile = path.join(os.tmpdir(), `vox_region_${Date.now()}.jpg`)
|
|
92
|
+
try {
|
|
93
|
+
await execAbortable(
|
|
94
|
+
`screencapture -R ${xi},${yi},${wi},${hi} -t jpg "${tmpFile}"`,
|
|
95
|
+
{
|
|
96
|
+
timeout: 10_000
|
|
97
|
+
},
|
|
98
|
+
signal
|
|
99
|
+
)
|
|
100
|
+
const buffer = await fs.readFile(tmpFile)
|
|
101
|
+
return {
|
|
102
|
+
text: `Captured region (${wi}x${hi}) at (${xi},${yi}). Coordinates map directly to click_at screen coordinates.`,
|
|
103
|
+
imageBase64: buffer.toString('base64'),
|
|
104
|
+
mimeType: 'image/jpeg'
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
await fs.unlink(tmpFile).catch(() => {})
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { systemPreferences, shell } from 'electron'
|
|
2
|
+
|
|
3
|
+
const openAccessibilitySettings = () => {
|
|
4
|
+
shell.openExternal(
|
|
5
|
+
'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility'
|
|
6
|
+
)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const ensureAccessibility = () => {
|
|
10
|
+
const trusted = systemPreferences.isTrustedAccessibilityClient(false)
|
|
11
|
+
if (!trusted) {
|
|
12
|
+
const prompted = systemPreferences.isTrustedAccessibilityClient(true)
|
|
13
|
+
if (!prompted) {
|
|
14
|
+
openAccessibilitySettings()
|
|
15
|
+
throw Object.assign(
|
|
16
|
+
new Error(
|
|
17
|
+
'Vox needs Accessibility permission to control the screen. Opening System Settings → Privacy & Security → Accessibility — please enable it for Vox and try again.'
|
|
18
|
+
),
|
|
19
|
+
{
|
|
20
|
+
code: 'ACCESSIBILITY_REQUIRED'
|
|
21
|
+
}
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { enqueueScreen } from '../../queue.js'
|
|
2
|
+
import {
|
|
3
|
+
ensureAccessibility,
|
|
4
|
+
UI_ELEMENTS_SCRIPT,
|
|
5
|
+
LONG_TIMEOUT,
|
|
6
|
+
execAbortable,
|
|
7
|
+
writeTmp,
|
|
8
|
+
cleanTmp
|
|
9
|
+
} from './helpers.js'
|
|
10
|
+
|
|
11
|
+
export const getUiElements = ({ app, maxElements } = {}, { signal } = {}) =>
|
|
12
|
+
enqueueScreen(async () => {
|
|
13
|
+
ensureAccessibility()
|
|
14
|
+
const limit = Math.max(1, Math.min(1000, Number(maxElements) || 200))
|
|
15
|
+
let script = UI_ELEMENTS_SCRIPT
|
|
16
|
+
if (app) {
|
|
17
|
+
script = UI_ELEMENTS_SCRIPT.replace(
|
|
18
|
+
'var proc = se.processes.whose({ frontmost: true })[0];',
|
|
19
|
+
`var proc = se.processes.whose({ name: "${String(app).replace(/"/g, '\\"')}" })[0];`
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
const file = await writeTmp(script, 'js')
|
|
23
|
+
try {
|
|
24
|
+
const { stdout } = await execAbortable(
|
|
25
|
+
`osascript -l JavaScript "${file}"`,
|
|
26
|
+
{ timeout: LONG_TIMEOUT },
|
|
27
|
+
signal
|
|
28
|
+
)
|
|
29
|
+
const all = JSON.parse(stdout.trim())
|
|
30
|
+
const elements = Array.isArray(all) ? all : (all?.elements ?? [])
|
|
31
|
+
const total = elements.length
|
|
32
|
+
return { elements: elements.slice(0, limit), total, truncated: total > limit }
|
|
33
|
+
} catch (err) {
|
|
34
|
+
throw new Error(`UI element inspection failed: ${err?.message || err}`)
|
|
35
|
+
} finally {
|
|
36
|
+
await cleanTmp(file)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
export const focusApp = async ({ app }, { signal } = {}) => {
|
|
41
|
+
await execAbortable(`open -a ${JSON.stringify(app)}`, { timeout: 10_000 }, signal)
|
|
42
|
+
return { action: 'focus_app', app }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const launchApp = async ({ app, args = [] }, { signal } = {}) => {
|
|
46
|
+
const argStr =
|
|
47
|
+
Array.isArray(args) && args.length
|
|
48
|
+
? ` --args ${args.map((a) => JSON.stringify(a)).join(' ')}`
|
|
49
|
+
: ''
|
|
50
|
+
await execAbortable(`open -a ${JSON.stringify(app)}${argStr}`, { timeout: 15_000 }, signal)
|
|
51
|
+
return { action: 'launch_app', app }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const listApps = async (_, { signal } = {}) => {
|
|
55
|
+
const { stdout } = await execAbortable('ls /Applications/', { timeout: 10_000 }, signal)
|
|
56
|
+
const apps = stdout
|
|
57
|
+
.trim()
|
|
58
|
+
.split('\n')
|
|
59
|
+
.filter((a) => a.endsWith('.app'))
|
|
60
|
+
.map((a) => a.replace(/\.app$/, ''))
|
|
61
|
+
return { apps }
|
|
62
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { exec } from 'child_process'
|
|
2
|
+
import fs from 'fs/promises'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
import path from 'path'
|
|
5
|
+
|
|
6
|
+
const EXEC_TIMEOUT = 30_000
|
|
7
|
+
|
|
8
|
+
export const execAbortable = (command, options = {}, signal) => {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
if (signal?.aborted) {
|
|
11
|
+
reject(new Error('Aborted'))
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
const child = exec(command, options, (error, stdout, stderr) => {
|
|
15
|
+
if (signal) signal.removeEventListener('abort', onAbort)
|
|
16
|
+
if (error)
|
|
17
|
+
reject(
|
|
18
|
+
Object.assign(error, {
|
|
19
|
+
stderr
|
|
20
|
+
})
|
|
21
|
+
)
|
|
22
|
+
else
|
|
23
|
+
resolve({
|
|
24
|
+
stdout,
|
|
25
|
+
stderr
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
const onAbort = () => {
|
|
29
|
+
try {
|
|
30
|
+
child.kill('SIGTERM')
|
|
31
|
+
} catch {
|
|
32
|
+
void 0
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (signal)
|
|
36
|
+
signal.addEventListener('abort', onAbort, {
|
|
37
|
+
once: true
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const writeTmp = async (content, ext) => {
|
|
43
|
+
const file = path.join(
|
|
44
|
+
os.tmpdir(),
|
|
45
|
+
`vox_ctrl_${Date.now()}_${Math.random().toString(36).slice(2)}.${ext}`
|
|
46
|
+
)
|
|
47
|
+
await fs.writeFile(file, content, 'utf8')
|
|
48
|
+
return file
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const cleanTmp = (file) => fs.unlink(file).catch(() => {})
|
|
52
|
+
|
|
53
|
+
export const runPy = async (script, signal, timeout = EXEC_TIMEOUT) => {
|
|
54
|
+
const file = await writeTmp(script, 'py')
|
|
55
|
+
try {
|
|
56
|
+
return await execAbortable(
|
|
57
|
+
`python3 "${file}"`,
|
|
58
|
+
{
|
|
59
|
+
timeout
|
|
60
|
+
},
|
|
61
|
+
signal
|
|
62
|
+
)
|
|
63
|
+
} finally {
|
|
64
|
+
await cleanTmp(file)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { clipboard } from 'electron'
|
|
2
|
+
export * from './mouse.js'
|
|
3
|
+
export * from './keyboard.js'
|
|
4
|
+
export * from './apps.js'
|
|
5
|
+
|
|
6
|
+
export const clipboardRead = () => ({ text: clipboard.readText() })
|
|
7
|
+
export const clipboardWrite = ({ text }) => {
|
|
8
|
+
clipboard.writeText(String(text || ''))
|
|
9
|
+
return { ok: true }
|
|
10
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { enqueueScreen } from '../../queue.js'
|
|
2
|
+
import {
|
|
3
|
+
ensureAccessibility,
|
|
4
|
+
CHAR_CODES,
|
|
5
|
+
KEY_CODES,
|
|
6
|
+
pyTypeText,
|
|
7
|
+
pyKeyCode,
|
|
8
|
+
pyCharKey,
|
|
9
|
+
runPy
|
|
10
|
+
} from './helpers.js'
|
|
11
|
+
|
|
12
|
+
export const typeText = ({ text }, { signal } = {}) =>
|
|
13
|
+
enqueueScreen(async () => {
|
|
14
|
+
ensureAccessibility()
|
|
15
|
+
if (!text) throw new Error('"text" is required.')
|
|
16
|
+
await runPy(pyTypeText(text), signal)
|
|
17
|
+
return { action: 'type', length: text.length }
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
export const keyPress = ({ key, modifiers = [] }, { signal } = {}) =>
|
|
21
|
+
enqueueScreen(async () => {
|
|
22
|
+
ensureAccessibility()
|
|
23
|
+
if (!key) throw new Error('"key" is required.')
|
|
24
|
+
const keyLower = String(key).toLowerCase().trim()
|
|
25
|
+
const mods = (Array.isArray(modifiers) ? modifiers : [modifiers]).filter(Boolean)
|
|
26
|
+
const keyCode = KEY_CODES[keyLower] ?? CHAR_CODES[keyLower]
|
|
27
|
+
if (keyCode !== undefined) {
|
|
28
|
+
await runPy(pyKeyCode(keyCode, mods), signal)
|
|
29
|
+
} else {
|
|
30
|
+
const b64 = Buffer.from(keyLower, 'utf8').toString('base64')
|
|
31
|
+
await runPy(pyCharKey(b64, mods), signal)
|
|
32
|
+
}
|
|
33
|
+
return { action: 'key_press', key, modifiers: mods }
|
|
34
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export const KEY_CODES = {
|
|
2
|
+
return: 36,
|
|
3
|
+
enter: 36,
|
|
4
|
+
tab: 48,
|
|
5
|
+
space: 49,
|
|
6
|
+
delete: 51,
|
|
7
|
+
backspace: 51,
|
|
8
|
+
escape: 53,
|
|
9
|
+
esc: 53,
|
|
10
|
+
command: 55,
|
|
11
|
+
cmd: 55,
|
|
12
|
+
shift: 56,
|
|
13
|
+
option: 58,
|
|
14
|
+
alt: 58,
|
|
15
|
+
control: 59,
|
|
16
|
+
ctrl: 59,
|
|
17
|
+
left: 123,
|
|
18
|
+
right: 124,
|
|
19
|
+
down: 125,
|
|
20
|
+
up: 126,
|
|
21
|
+
f1: 122,
|
|
22
|
+
f2: 120,
|
|
23
|
+
f3: 99,
|
|
24
|
+
f4: 118,
|
|
25
|
+
f5: 96,
|
|
26
|
+
f6: 97,
|
|
27
|
+
f7: 98,
|
|
28
|
+
f8: 100,
|
|
29
|
+
f9: 101,
|
|
30
|
+
f10: 109,
|
|
31
|
+
f11: 103,
|
|
32
|
+
f12: 111,
|
|
33
|
+
home: 115,
|
|
34
|
+
end: 119,
|
|
35
|
+
pageup: 116,
|
|
36
|
+
pagedown: 121
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const CHAR_CODES = {
|
|
40
|
+
a: 0,
|
|
41
|
+
s: 1,
|
|
42
|
+
d: 2,
|
|
43
|
+
f: 3,
|
|
44
|
+
h: 4,
|
|
45
|
+
g: 5,
|
|
46
|
+
z: 6,
|
|
47
|
+
x: 7,
|
|
48
|
+
c: 8,
|
|
49
|
+
v: 9,
|
|
50
|
+
b: 11,
|
|
51
|
+
q: 12,
|
|
52
|
+
w: 13,
|
|
53
|
+
e: 14,
|
|
54
|
+
r: 15,
|
|
55
|
+
y: 16,
|
|
56
|
+
t: 17,
|
|
57
|
+
1: 18,
|
|
58
|
+
2: 19,
|
|
59
|
+
3: 20,
|
|
60
|
+
4: 21,
|
|
61
|
+
6: 22,
|
|
62
|
+
5: 23,
|
|
63
|
+
9: 25,
|
|
64
|
+
7: 26,
|
|
65
|
+
8: 28,
|
|
66
|
+
0: 29,
|
|
67
|
+
o: 31,
|
|
68
|
+
u: 32,
|
|
69
|
+
i: 34,
|
|
70
|
+
p: 35,
|
|
71
|
+
l: 37,
|
|
72
|
+
j: 38,
|
|
73
|
+
k: 40,
|
|
74
|
+
n: 45,
|
|
75
|
+
m: 46,
|
|
76
|
+
',': 43,
|
|
77
|
+
'.': 47,
|
|
78
|
+
'/': 44,
|
|
79
|
+
';': 41,
|
|
80
|
+
"'": 39,
|
|
81
|
+
'[': 33,
|
|
82
|
+
']': 30,
|
|
83
|
+
'\\': 42,
|
|
84
|
+
'-': 27,
|
|
85
|
+
'=': 24,
|
|
86
|
+
'`': 50
|
|
87
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { enqueueScreen } from '../../queue.js'
|
|
2
|
+
import {
|
|
3
|
+
ensureAccessibility,
|
|
4
|
+
pyClick,
|
|
5
|
+
pyMove,
|
|
6
|
+
pyDrag,
|
|
7
|
+
pyScroll,
|
|
8
|
+
pyGetMousePos,
|
|
9
|
+
runPy
|
|
10
|
+
} from './helpers.js'
|
|
11
|
+
|
|
12
|
+
export const clickAt = ({ x, y, button = 'left', count = 1 }, { signal } = {}) =>
|
|
13
|
+
enqueueScreen(async () => {
|
|
14
|
+
ensureAccessibility()
|
|
15
|
+
const xInt = Math.round(Number(x))
|
|
16
|
+
const yInt = Math.round(Number(y))
|
|
17
|
+
const btn = button === 'right' ? 'right' : 'left'
|
|
18
|
+
const clicks = Math.max(1, Math.min(3, Number(count)))
|
|
19
|
+
await runPy(pyClick(xInt, yInt, btn, clicks), signal)
|
|
20
|
+
return { action: 'click', x: xInt, y: yInt, button: btn, count: clicks }
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export const moveMouse = ({ x, y }, { signal } = {}) =>
|
|
24
|
+
enqueueScreen(async () => {
|
|
25
|
+
ensureAccessibility()
|
|
26
|
+
const xInt = Math.round(Number(x))
|
|
27
|
+
const yInt = Math.round(Number(y))
|
|
28
|
+
await runPy(pyMove(xInt, yInt), signal)
|
|
29
|
+
return { action: 'move', x: xInt, y: yInt }
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export const scroll = ({ x, y, deltaX = 0, deltaY = -3 }, { signal } = {}) =>
|
|
33
|
+
enqueueScreen(async () => {
|
|
34
|
+
ensureAccessibility()
|
|
35
|
+
const xInt = Math.round(Number(x))
|
|
36
|
+
const yInt = Math.round(Number(y))
|
|
37
|
+
const dx = Math.round(Number(deltaX))
|
|
38
|
+
const dy = Math.round(Number(deltaY))
|
|
39
|
+
await runPy(pyScroll(xInt, yInt, dx, dy), signal)
|
|
40
|
+
return { action: 'scroll', x: xInt, y: yInt, deltaX: dx, deltaY: dy }
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
export const drag = ({ fromX, fromY, toX, toY }, { signal } = {}) =>
|
|
44
|
+
enqueueScreen(async () => {
|
|
45
|
+
ensureAccessibility()
|
|
46
|
+
const x1 = Math.round(Number(fromX))
|
|
47
|
+
const y1 = Math.round(Number(fromY))
|
|
48
|
+
const x2 = Math.round(Number(toX))
|
|
49
|
+
const y2 = Math.round(Number(toY))
|
|
50
|
+
await runPy(pyDrag(x1, y1, x2, y2), signal)
|
|
51
|
+
return { action: 'drag', from: { x: x1, y: y1 }, to: { x: x2, y: y2 } }
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
export const getMousePosition = (_, { signal } = {}) =>
|
|
55
|
+
enqueueScreen(async () => {
|
|
56
|
+
const { stdout } = await runPy(pyGetMousePos(), signal)
|
|
57
|
+
const [x, y] = stdout.trim().split(',').map(Number)
|
|
58
|
+
return { x: x ?? 0, y: y ?? 0 }
|
|
59
|
+
})
|