Skip to content

Commit 244c95e

Browse files
committed
feat: add macOS app management tools
1 parent 36eb1ce commit 244c95e

File tree

5 files changed

+202
-2
lines changed

5 files changed

+202
-2
lines changed

src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import yargs, { type ArgumentsCamelCase } from 'yargs'
33
import { hideBin } from 'yargs/helpers'
44
import { startWebServer, startStdioServer } from './services'
5-
import { getOptions } from './utils'
5+
import { getApps, getOptions } from './utils'
66
import 'dotenv/config'
77
import pkg from '../package.json' with { type: 'json' }
88

@@ -38,9 +38,11 @@ if (!argv._[0]) {
3838
}
3939

4040
async function startServer(mode: string, argv: ArgumentsCamelCase) {
41+
const apps = getApps()
4142
const options = getOptions(argv, {
4243
name,
4344
version: pkg.version,
45+
apps,
4446
})
4547
if (mode === 'stdio') {
4648
startStdioServer(options).catch(console.error)

src/tools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import registerGetData from './registerGetData'
2+
import registerMacOs from './registerMacOs'
23
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
34
import type { OptionsType } from '@/types'
45

56
export const registerTools = (server: McpServer, options: OptionsType) => {
67
registerGetData(server, options)
8+
registerMacOs(server, options)
79
}

src/tools/registerMacOs.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { z } from 'zod'
2+
import { getOSType } from '@/utils'
3+
import { exec } from 'child_process'
4+
import { promisify } from 'util'
5+
import { setTimeout as sleep } from 'timers/promises'
6+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
7+
import type { OptionsType } from '@/types'
8+
9+
const execPromise = promisify(exec)
10+
11+
export default function register(server: McpServer, options: OptionsType) {
12+
const openMacOsAppTool = server.registerTool(
13+
'MacOs Open App',
14+
{
15+
title: 'MacOs Open App',
16+
description: 'MacOs Open App',
17+
inputSchema: {
18+
appName: z.string().describe('app name'),
19+
},
20+
},
21+
async ({ appName }) => {
22+
const { success, data, message } = await openMacOsApp(appName, options)
23+
await sleep(2000)
24+
return {
25+
content: [
26+
{
27+
type: 'text',
28+
text: success ? data! : message!,
29+
},
30+
],
31+
}
32+
},
33+
)
34+
35+
const findInAppTool = server.registerTool(
36+
'Find In App',
37+
{
38+
title: 'Find In App',
39+
description: 'Find In App',
40+
inputSchema: {
41+
appPath: z.string().describe('app path'),
42+
keyword: z.string().describe('search keyword'),
43+
},
44+
},
45+
async ({ appPath, keyword }) => {
46+
await findInApp(appPath, keyword, options)
47+
await sleep(2000)
48+
return {
49+
content: [
50+
{
51+
type: 'text',
52+
text: `find ${keyword} in ${appPath}`,
53+
},
54+
],
55+
}
56+
},
57+
)
58+
59+
const inputMessageTool = server.registerTool(
60+
'Input Message',
61+
{
62+
title: 'Input Message',
63+
description: 'Input Message',
64+
inputSchema: {
65+
appPath: z.string().describe('app path'),
66+
message: z.string().describe('input message'),
67+
},
68+
},
69+
async ({ appPath, message }) => {
70+
await inputMessage(appPath, message, options)
71+
await sleep(2000)
72+
return {
73+
content: [
74+
{
75+
type: 'text',
76+
text: `input ${message} in ${appPath}`,
77+
},
78+
],
79+
}
80+
},
81+
)
82+
83+
if (getOSType() !== 'macOS') {
84+
openMacOsAppTool.disable()
85+
findInAppTool.disable()
86+
inputMessageTool.disable()
87+
}
88+
}
89+
90+
export const openMacOsApp = async (appName: string, options: OptionsType) => {
91+
const app = options.apps.find(item => item.name === appName)
92+
if (!app) {
93+
return {
94+
success: false,
95+
message: `app ${appName} not found`,
96+
}
97+
}
98+
try {
99+
await execPromise(`open "${app.path}"`)
100+
} catch (error) {
101+
return {
102+
success: false,
103+
message: `open app ${app.name} failed: ${(error as Error).message}`,
104+
}
105+
}
106+
return {
107+
success: true,
108+
data: `appName: ${app.name}; appVersion: ${app.version}; appPath: ${app.path};`,
109+
}
110+
}
111+
112+
export const findInApp = async (appPath: string, keyword: string, options: OptionsType) => {
113+
await execPromise(`osascript -e 'set originalClipboard to the clipboard
114+
set the clipboard to "${keyword}"
115+
tell application "${appPath}"
116+
activate
117+
delay 1
118+
tell application "System Events"
119+
key code 3 using command down
120+
delay 1
121+
key code 0 using command down
122+
delay 0.2
123+
key code 9 using command down
124+
delay 1
125+
key code 36
126+
end tell
127+
end tell
128+
set the clipboard to originalClipboard'`)
129+
}
130+
131+
export const inputMessage = async (appPath: string, message: string, options: OptionsType) => {
132+
await execPromise(`echo '${message}' | pbcopy`)
133+
await execPromise(`osascript -e 'set originalClipboard to the clipboard
134+
set the clipboard to "${message}"
135+
tell application "${appPath}"
136+
activate
137+
tell application "System Events"
138+
key code 53
139+
delay 0.3
140+
key code 0 using command down
141+
delay 0.2
142+
key code 9 using command down
143+
delay 1
144+
key code 36
145+
end tell
146+
end tell
147+
set the clipboard to originalClipboard'`)
148+
}

src/types/global.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,11 @@ export interface OptionsType {
22
name: string
33
version: string
44
port: number
5+
apps: AppType[]
6+
}
7+
8+
export interface AppType {
9+
name: string
10+
version: string
11+
path: string
512
}

src/utils/index.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,57 @@
1+
import * as os from 'os'
2+
import { execSync } from 'child_process'
13
import type { ArgumentsCamelCase } from 'yargs'
2-
import type { OptionsType } from '@/types'
4+
import type { AppType, OptionsType } from '@/types'
5+
6+
export const getOSType = (() => {
7+
const platform = os.platform()
8+
const osType =
9+
platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : platform === 'linux' ? 'Linux' : platform
10+
return () => osType
11+
})()
312

413
export function getOptions(
514
argv: ArgumentsCamelCase,
615
pkg: {
716
name: string
817
version: string
18+
apps: AppType[]
919
},
1020
) {
1121
return {
1222
name: pkg.name,
1323
version: pkg.version,
24+
apps: pkg.apps,
1425
port: argv.port,
1526
} as OptionsType
1627
}
28+
29+
export function tryParseJSON(json: string, defaultValue = null) {
30+
try {
31+
return JSON.parse(json)
32+
} catch {
33+
return defaultValue
34+
}
35+
}
36+
37+
export function getApps(): AppType[] {
38+
const osType = getOSType()
39+
if (osType === 'macOS') {
40+
const output = execSync('system_profiler SPApplicationsDataType -json', {
41+
encoding: 'utf8',
42+
maxBuffer: 1024 * 1024 * 10,
43+
})
44+
45+
const data = tryParseJSON(output)
46+
return (
47+
data?.SPApplicationsDataType.map((item: any) => {
48+
return {
49+
name: item._name,
50+
version: item.version,
51+
path: item.path,
52+
}
53+
}) || []
54+
)
55+
}
56+
return []
57+
}

0 commit comments

Comments
 (0)