├── .eslintignore
├── .eslintrc.js
├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── README.md
├── bin
└── macaca-macos.ts
├── build.sh
├── index.ts
├── package.json
├── playground
├── .eslintrc.js
├── README.md
└── play-calculator.js
├── resource
├── applescript
│ ├── readme.md
│ ├── scpt
│ │ └── mouseClick.scpt
│ ├── scptd
│ │ └── window.scptd
│ │ │ └── Contents
│ │ │ ├── Info.plist
│ │ │ └── Resources
│ │ │ ├── Scripts
│ │ │ └── main.scpt
│ │ │ └── description.rtfd
│ │ │ └── TXT.rtf
│ └── src
│ │ ├── mouseClick.applescript
│ │ └── window.applescript
├── javascript
│ └── mouse.js
└── swift
│ ├── build.sh
│ ├── mouse-drag-arm64
│ ├── mouse-drag-x64
│ ├── ocr-arm64
│ ├── ocr-x64
│ ├── readme.md
│ └── src
│ ├── LeftMouseDragged
│ ├── .gitignore
│ ├── Package.swift
│ └── Sources
│ │ └── main.swift
│ └── MacosOcr
│ ├── .gitignore
│ ├── Package.swift
│ └── Sources
│ └── main.swift
├── src
├── core
│ ├── enums.ts
│ ├── helper.ts
│ ├── jxa
│ │ ├── exec-jxa.ts
│ │ ├── jxaUtil.ts
│ │ └── osaUtil.ts
│ └── mixin.ts
├── driver
│ ├── app.ts
│ ├── clipboard.ts
│ ├── keyboard.ts
│ ├── mouse.ts
│ ├── network.ts
│ ├── screen.ts
│ └── video.ts
└── macaca-macos.ts
├── test
├── jxaUtil.test.ts
├── macaca-macos.test.ts
├── mocha.opts
└── osaUtil.test.ts
└── tsconfig.json
/.eslintignore:
--------------------------------------------------------------------------------
1 | **/*.d.ts
2 | node_modules
3 | dist
4 | coverage
5 | .nyc_output
6 | resource
7 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 |
2 | module.exports = {
3 | root: true,
4 | extends: 'eslint-config-egg/typescript',
5 | rules: {
6 | 'valid-jsdoc': 0,
7 | 'no-script-url': 0,
8 | 'no-multi-spaces': 0,
9 | 'default-case': 0,
10 | 'no-case-declarations': 0,
11 | 'one-var-declaration-per-line': 0,
12 | 'no-restricted-syntax': 0,
13 | 'jsdoc/require-param': 0,
14 | 'jsdoc/check-param-names': 0,
15 | 'jsdoc/require-param-description': 0,
16 | 'arrow-parens': 0,
17 | 'prefer-promise-reject-errors': 0,
18 | 'no-control-regex': 0,
19 | 'no-use-before-define': 0,
20 | 'array-callback-return': 0,
21 | 'no-bitwise': 0,
22 | 'no-self-compare': 0,
23 | '@typescript-eslint/no-var-requires': 0,
24 | '@typescript-eslint/ban-ts-ignore': 0,
25 | '@typescript-eslint/no-use-before-define': 0,
26 | 'one-var': 0,
27 | 'no-sparse-arrays': 0,
28 | 'no-useless-concat': 0,
29 | '@typescript-eslint/ban-types': 0,
30 | },
31 | };
32 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | workflow_dispatch:
5 |
6 | push:
7 | branches:
8 | - '**'
9 | paths-ignore:
10 | - '**.md'
11 |
12 | jobs:
13 | Runner:
14 | timeout-minutes: 10
15 | runs-on: macOS-latest
16 | steps:
17 | - name: Checkout Git Source
18 | uses: actions/checkout@v3
19 |
20 | - name: Setup Node.js
21 | uses: actions/setup-node@v3
22 | with:
23 | node-version: ${{ matrix.node-version }}
24 |
25 | - name: Install dependencies
26 | run: |
27 | npm i npm@6 -g
28 | npm i
29 |
30 | - name: Continuous integration
31 | run: npm run test
32 |
33 | - name: Code coverage
34 | uses: codecov/codecov-action@v3.0.0
35 | with:
36 | token: ${{ secrets.CODECOV_TOKEN }}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .nyc_output
3 | coverage
4 | .idea
5 | .run
6 | dist
7 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # macaca-macos
2 |
3 | ---
4 |
5 | [![NPM version][npm-image]][npm-url]
6 | [![CI][ci-image]][ci-url]
7 | [![Test coverage][codecov-image]][codecov-url]
8 | [![node version][node-image]][node-url]
9 |
10 | [npm-image]: https://img.shields.io/npm/v/macaca-macos.svg?logo=npm
11 | [npm-url]: https://npmjs.org/package/macaca-macos
12 | [ci-image]: https://github.com/macacajs/macaca-macos/actions/workflows/ci.yml/badge.svg
13 | [ci-url]: https://github.com/macacajs/macaca-macos/actions/workflows/ci.yml
14 | [codecov-image]: https://img.shields.io/codecov/c/github/macacajs/macaca-macos.svg?logo=codecov
15 | [codecov-url]: https://codecov.io/gh/macacajs/macaca-macos
16 | [node-image]: https://img.shields.io/badge/node.js-%3E=_16-green.svg?logo=node.js
17 | [node-url]: http://nodejs.org/download/
18 |
19 | > Integrate [robotjs](https://github.com/octalmage/robotjs), [osascript](https://developer.apple.com/documentation/uniformtypeidentifiers/uttype/3551541-osascript) and shell of the operating system to automatically control the MacOS, such as controlling the mouse, keyboard, window operation, etc.
20 |
21 | ## Installment
22 |
23 | ```bash
24 | $ npm i macaca-macos --save-dev
25 | ```
26 |
27 | ## Usage
28 | ```ts
29 | import MacacaMacOS from 'macaca-macos';
30 |
31 | const driver = new MacacaMacOS();
32 | await driver.startApp('/System/Applications/Notes.app');
33 | ```
34 |
35 |
36 |
37 | ## Contributors
38 |
39 | |[
xudafeng](https://github.com/xudafeng)
|[
yihuineng](https://github.com/yihuineng)
|[
snapre](https://github.com/snapre)
|
40 | | :---: | :---: | :---: |
41 |
42 |
43 | This project follows the git-contributor [spec](https://github.com/xudafeng/git-contributor), auto updated at `Mon Aug 29 2022 00:09:09 GMT+0800`.
44 |
45 |
46 |
47 | ## Star History
48 |
49 | [](https://star-history.com/#macacajs/macaca-macos&Date)
50 |
--------------------------------------------------------------------------------
/bin/macaca-macos.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ts-node
2 |
3 | /**
4 | * @description macaca-macos cli工具
5 | */
6 |
7 | import MacacaMacOS from '..';
8 | import { Helper } from '../src/core/helper';
9 | import path from 'path';
10 |
11 | process.setMaxListeners(0);
12 | process.on('uncaughtException', function(err) {
13 | console.log('Caught exception: ' + err);
14 | process.exit(1);
15 | });
16 |
17 | const { program } = require('commander');
18 | const os = require('os');
19 | const chalk = require('chalk');
20 |
21 | const getLogoStr = () => {
22 | const lines = [
23 | ' __ __ __ __ ___ ____ ',
24 | '| \\/ | __ _ ___ __ _ ___ __ _ | \\/ | __ _ ___ / _ \\/ ___| ',
25 | '| |\\/| |/ _` |/ __/ _` |/ __/ _` | | |\\/| |/ _` |/ __| | | \\___ \\',
26 | '| | | | (_| | (_| (_| | (_| (_| | | | | | (_| | (__| |_| |___) |',
27 | '|_| |_|\\__,_|\\___\\__,_|\\___\\__,_| |_| |_|\\__,_|\\___|\\___/|____/ ',
28 | ];
29 | return chalk.green(lines.join(os.EOL));
30 | };
31 |
32 | program
33 | .addHelpText('before', getLogoStr())
34 | .version(Helper.getPkgVersion());
35 |
36 | program
37 | .command('relative_mouse_pos [appName]')
38 | .alias('rmp')
39 | .description('获取鼠标在APP上的相对位置(app界面左上角坐标: 0, 0)')
40 | .option('-c, --color', '顺带获取点位颜色hex值', false)
41 | .action(async (appName, opts) => {
42 | const { color } = opts;
43 | const driver = new MacacaMacOS();
44 | const realPos = driver.mouseGetPos();
45 | const appPos = await driver.getAppSizePosition(appName);
46 | let relativePos = `${realPos.x - appPos.topLeftX},${realPos.y - appPos.topLeftY}`;
47 | if (color) {
48 | const colorHex = driver.getPixelColor(realPos.x, realPos.y);
49 | relativePos = `${relativePos} ${colorHex}`;
50 | }
51 | await driver.setClipText(relativePos);
52 | console.log(`${appName}窗口相对坐标: ${relativePos} 已复制到剪贴板`);
53 | });
54 |
55 | program
56 | .command('resize [appName]')
57 | .description('设置窗口大小和位置')
58 | .option('-w, --width ', '宽', parseInt)
59 | .option('-h, --height ', '高', parseInt)
60 | .option('-p, --position ', '设置app左上角起点 x,y', '0,100')
61 | .action(async (appName, opts) => {
62 | const { position, width, height } = opts;
63 | const driver = new MacacaMacOS();
64 | await driver.resizePosition({
65 | name: appName,
66 | topLeftX: Number.parseInt(position.split(',')[0]),
67 | topLeftY: Number.parseInt(position.split(',')[1]),
68 | width, height,
69 | });
70 | console.log('success');
71 | });
72 |
73 | program
74 | .command('ocr [target]')
75 | .description('app或图片ocr')
76 | .action(async (target) => {
77 | const driver = new MacacaMacOS();
78 | let res;
79 | if (target.endsWith('.png') || target.endsWith('.jpg')) {
80 | target = target.startsWith('/') ? target : path.resolve(process.cwd(), target);
81 | res = await driver.screenOcr({
82 | picFile: target,
83 | });
84 | } else {
85 | const appName = target;
86 | await driver.focusApp(appName);
87 | const rect = await driver.getAppSizePosition(appName);
88 | res = await driver.screenOcr({
89 | rectangle: `${rect.topLeftX},${rect.topLeftY},${rect.width},${rect.height}`,
90 | });
91 | }
92 | console.log(JSON.stringify(res, null, 2));
93 | });
94 |
95 | program.parse(process.argv);
96 |
--------------------------------------------------------------------------------
/build.sh:
--------------------------------------------------------------------------------
1 | # 1. clean dist
2 | rm -rf ./dist
3 |
4 | # 2. tsc compile
5 | `npm bin`/tsc
6 |
7 | # 3. move resource
8 | cp -r ./resource ./dist/resource
9 |
10 | # 4. delete unused resource files
11 | rm -rf ./dist/resource/swift/src
12 | rm -rf ./dist/resource/swift/build.sh
13 |
14 | # 4. replace ts-node to node
15 | grep -rl 'ts-node' ./dist/bin | xargs sed -i '' 's/ts-node/node/g'
16 |
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
1 | import MacacaMacOS from './src/macaca-macos';
2 |
3 | export default MacacaMacOS;
4 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "macaca-macos",
3 | "version": "0.3.0",
4 | "description": "Macaca MacOS driver",
5 | "keywords": [
6 | "macos",
7 | "macaca"
8 | ],
9 | "files": [
10 | "dist"
11 | ],
12 | "main": "dist/index",
13 | "typings": "dist/index.d.ts",
14 | "repository": {
15 | "type": "git",
16 | "url": "https://github.com/macacajs/macaca-macos"
17 | },
18 | "dependencies": {
19 | "applescript": "^1.0.0",
20 | "chalk": "^4.1.0",
21 | "commander": "^7.2.0",
22 | "npm-update": "3",
23 | "shelljs": "^0.8.5"
24 | },
25 | "optionalDependencies": {
26 | "robotjs": "^0.6.0"
27 | },
28 | "devDependencies": {
29 | "@jxa/global-type": "^1.3.5",
30 | "@jxa/types": "^1.3.5",
31 | "@types/mocha": "9",
32 | "@types/node": "^16.11.12",
33 | "eslint": "8",
34 | "eslint-config-egg": "12",
35 | "git-contributor": "1",
36 | "husky": "^1.3.1",
37 | "macaca-ecosystem": "1",
38 | "mocha": "8",
39 | "nyc": "^13.1.0",
40 | "ts-node": "^10.9.1",
41 | "typescript": "4"
42 | },
43 | "bin": {
44 | "macaca-macos": "./dist/bin/macaca-macos.js"
45 | },
46 | "scripts": {
47 | "test": "nyc --reporter=lcov --reporter=text mocha 'test/**/*.test.ts' --require ts-node/register --recursive --timeout 600000",
48 | "lint": "eslint . --ext .ts,js",
49 | "build": "sh ./build.sh",
50 | "prepublishOnly": "npm run build",
51 | "contributor": "git-contributor"
52 | },
53 | "husky": {
54 | "hooks": {
55 | "pre-commit": "npm run lint"
56 | }
57 | },
58 | "license": "MIT"
59 | }
60 |
--------------------------------------------------------------------------------
/playground/.eslintrc.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = {
4 | root: true,
5 | extends: 'eslint-config-egg',
6 | rules: {
7 | 'valid-jsdoc': 0,
8 | 'no-script-url': 0,
9 | 'no-multi-spaces': 0,
10 | 'default-case': 0,
11 | 'no-case-declarations': 0,
12 | 'one-var-declaration-per-line': 0,
13 | 'no-restricted-syntax': 0,
14 | 'jsdoc/require-param': 0,
15 | 'jsdoc/check-param-names': 0,
16 | 'jsdoc/require-param-description': 0,
17 | 'arrow-parens': 0,
18 | 'prefer-promise-reject-errors': 0,
19 | 'no-control-regex': 0,
20 | 'no-use-before-define': 0,
21 | 'array-callback-return': 0,
22 | 'no-bitwise': 0,
23 | 'no-self-compare': 0,
24 | 'one-var': 0,
25 | 'no-trailing-spaces': [ 'warn', { skipBlankLines: true }],
26 | 'no-return-await': 0,
27 | },
28 | };
29 |
--------------------------------------------------------------------------------
/playground/README.md:
--------------------------------------------------------------------------------
1 | # Macaca MacOS Playground
2 |
3 | ---
4 |
5 | ## Calculator
6 |
7 | ```bash
8 | $ node ./play-calculator.js
9 | ```
10 |
--------------------------------------------------------------------------------
/playground/play-calculator.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const { default: MacacaMacOS } = require('../dist');
4 |
5 | const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
6 |
7 | async function main() {
8 | const driver = new MacacaMacOS();
9 | await driver.startApp('/System/Applications/Calculator.app');
10 | const isRunning = await driver.isAppRunning('Calculator');
11 | console.log('app is running: %s', isRunning);
12 | await sleep(3E3);
13 | await driver.keyboardTap('numpad_1');
14 | await sleep(3E3);
15 | await driver.keyboardTap('numpad_1');
16 | await sleep(3E3);
17 | await driver.keyboardTap('numpad_1');
18 | await sleep(3E3);
19 | await driver.keyboardTap('numpad_1');
20 | }
21 |
22 | main().then().catch(console.log);
23 |
--------------------------------------------------------------------------------
/resource/applescript/readme.md:
--------------------------------------------------------------------------------
1 | # applescript
2 |
3 | - src 目录
4 | - 脚本源码,(部分脚本无法直接执行需要通过 系统脚本编辑器软件 编译后执行)
5 | - 部分可通过 osaUtil 直接执行
6 | - scpt 目录
7 | - 使用 Mac系统脚本编辑器软件保存的脚本(已经过编译,可直接通过命令行运行)
8 | - shell方式 osascript ${file} ...args
9 | - scptd 目录
10 | - 使用 Mac系统脚本编辑器软件 导出的脚本包(经过编译,可通过jxa Library引用)
11 | - jxa方式 const window = Library('window');
12 |
13 | ---
14 |
15 | - 如需通过Library 使用 请将scptd目录下的文件拷贝至 ~/Library/Script Libraries 目录
16 |
--------------------------------------------------------------------------------
/resource/applescript/scpt/mouseClick.scpt:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/macacajs/macaca-macos/21a9783ca2b54f05d748ecb0e31bb2ee7701091c/resource/applescript/scpt/mouseClick.scpt
--------------------------------------------------------------------------------
/resource/applescript/scptd/window.scptd/Contents/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleIdentifier
6 | com.apple.ScriptEditor.id.window
7 | CFBundleName
8 | window
9 | CFBundleShortVersionString
10 | 1.0
11 | WindowState
12 |
13 | bundleDividerCollapsed
14 |
15 | bundlePositionOfDivider
16 | 0.0
17 | dividerCollapsed
18 |
19 | eventLogLevel
20 | 2
21 | name
22 | ScriptWindowState
23 | positionOfDivider
24 | 443
25 | savedFrame
26 | 20 395 700 678 0 0 1728 1079
27 | selectedTab
28 | result
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/resource/applescript/scptd/window.scptd/Contents/Resources/Scripts/main.scpt:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/macacajs/macaca-macos/21a9783ca2b54f05d748ecb0e31bb2ee7701091c/resource/applescript/scptd/window.scptd/Contents/Resources/Scripts/main.scpt
--------------------------------------------------------------------------------
/resource/applescript/scptd/window.scptd/Contents/Resources/description.rtfd/TXT.rtf:
--------------------------------------------------------------------------------
1 | {\rtf1\ansi\ansicpg936\cocoartf2639
2 | \cocoatextscaling0\cocoaplatform0{\fonttbl}
3 | {\colortbl;\red255\green255\blue255;}
4 | {\*\expandedcolortbl;;}
5 | }
--------------------------------------------------------------------------------
/resource/applescript/src/mouseClick.applescript:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env osascript
2 |
3 | on run argv
4 | set argList to {}
5 | repeat with arg in argv
6 | set end of argList to quoted form of arg
7 | end repeat
8 | set {TID, text item delimiters} to {text item delimiters, space}
9 | set argList to argList as text
10 | set text item delimiters to TID
11 |
12 | tell application "System Events"
13 | click at {item 1 of argv, item 2 of argv}
14 | end tell
15 | end run
16 |
--------------------------------------------------------------------------------
/resource/applescript/src/window.applescript:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env osascript
2 |
3 | -- 之所以有两种方法,是因为这两种方法并非总是生效的,具体软件具体情况不同
4 | -- 移动窗口
5 | on moveBounds(name, topLeftX, topLeftY, bottomRightX, bottomRightY)
6 | tell application name
7 | set bounds of front window to {topLeftX, topLeftY, bottomRightX, bottomRightY}
8 | end tell
9 | end moveBounds
10 |
11 | -- 设置窗口位置和大小 通过重定位
12 | on sizePosition(name, topLeftX, topLeftY, width, height)
13 | tell application "System Events" to tell application process name
14 | tell window 1
15 | set {position, size} to {{topLeftX, topLeftY}, {width, height}}
16 | end tell
17 | end tell
18 | end sizePosition
19 |
20 | -- 设置窗口位置
21 | on setPosition(name, topLeftX, topLeftY)
22 | tell application "System Events" to tell application process name
23 | tell window 1
24 | set {position} to {{topLeftX, topLeftY}}
25 | end tell
26 | end tell
27 | end setPosition
28 |
--------------------------------------------------------------------------------
/resource/javascript/mouse.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env osascript -l JavaScript
2 |
3 | // 控制鼠标的点击(左、右),鼠标拖拽、移动事件
4 | // 使用示例:
5 | // click()
6 | // drag(1068 + 122, 38,1068, 38)
7 | // move(652, 568)
8 |
9 | ObjC.import('Cocoa')
10 | ObjC.import('stdlib')
11 | ObjC.import('CoreGraphics')
12 |
13 |
14 | // 获取鼠标坐标=========================
15 |
16 | function location(screenH = 1050) {
17 | const mouseLoc = $.NSEvent.mouseLocation //获取 鼠标当前的的坐标(浮点数)
18 | mouseLoc.mx = parseInt(mouseLoc.x)
19 | mouseLoc.my = screenH - Math.trunc(mouseLoc.y) //坐标需要屏幕高度减获取的坐标
20 | return mouseLoc
21 | }
22 |
23 | // 鼠标的基本操作=========================
24 |
25 | const { mx, my } = location()
26 | const left_mouse_down = $.kCGEventLeftMouseDown //鼠标左键按下事件
27 | const right_mouse_down = $.kCGEventRightMouseDown //鼠标左键按下事件
28 | const left_mouse_up = $.kCGEventLeftMouseUp
29 | const right_mouse_up = $.kCGEventRightMouseUp
30 | const left_mouse_drag = $.kCGEventLeftMouseDragged
31 | const mouse_move = $.kCGEventMouseMoved
32 | const mouse_scroll = $.KCGEventScrollWheel
33 |
34 | // 用于注册鼠标事件
35 | function mouse_event(event_type, coords) {
36 | const nil = $()
37 | // const nil = 10
38 | // usleep(200000)
39 | const event = $.CGEventCreateMouseEvent(
40 | nil,
41 | event_type,
42 | coords,
43 | $.kCGMouseButtonLeft
44 | )
45 | $.CGEventPost($.kCGHIDEventTap, event)
46 | delay(0.01)//添加一点延迟,保证稳定
47 | // $.CFRelease(event)
48 | }
49 |
50 | function down(x = mx, y = my, r = false) {
51 | const coords = { x: x, y: y } //坐标对象
52 | const mouse_down = r ? right_mouse_down : left_mouse_down
53 | mouse_event(mouse_down, coords)
54 | }
55 |
56 | function up(x = mx, y = my, r = false) {
57 | const coords = { x: x, y: y } //坐标对象
58 | const mouse_up = r ? right_mouse_up : left_mouse_up
59 | mouse_event(mouse_up, coords)
60 | }
61 |
62 | function click(opts = {}) {
63 | const { x = mx, y = my, r = false } = opts;
64 | down(x, y, r)
65 | up(x, y, r)
66 | }
67 |
68 | // drag从特定位置按下,拖拽到指定位置
69 | function drag(tx, ty, cx = location().mx, cy = location().my) {
70 | const t_coords = { x: tx, y: ty } // 拖拽末尾坐标
71 | down(cx, cy)
72 | mouse_event(left_mouse_drag, t_coords)
73 | delay(0.5)
74 | up(tx, ty)
75 | }
76 |
77 | // move是在鼠标没有点击的状态下进行移动
78 | function move(x, y) {
79 | const coords = { x: x, y: y }
80 | mouse_event(mouse_move, coords)
81 | }
82 |
83 | exports.location = location;
84 | exports.click = click;
85 | exports.move = move;
86 | exports.drag = drag;
87 |
--------------------------------------------------------------------------------
/resource/swift/build.sh:
--------------------------------------------------------------------------------
1 | # 1. build MacosOcr project
2 | echo "☕️ start build ocr ..."
3 | cd ./src/MacosOcr
4 | swift build -c release
5 | cd ../../
6 | cp ./src/MacosOcr/.build/release/MacosOcr ./ocr
7 |
8 | # 2. build LeftMouseDragged project
9 | echo "☕️ start build mouse-drag ..."
10 | cd ./src/LeftMouseDragged
11 | swift build -c release
12 | cd ../../
13 | cp ./src/LeftMouseDragged/.build/release/LeftMouseDragged ./mouse-drag
--------------------------------------------------------------------------------
/resource/swift/mouse-drag-arm64:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/macacajs/macaca-macos/21a9783ca2b54f05d748ecb0e31bb2ee7701091c/resource/swift/mouse-drag-arm64
--------------------------------------------------------------------------------
/resource/swift/mouse-drag-x64:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/macacajs/macaca-macos/21a9783ca2b54f05d748ecb0e31bb2ee7701091c/resource/swift/mouse-drag-x64
--------------------------------------------------------------------------------
/resource/swift/ocr-arm64:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/macacajs/macaca-macos/21a9783ca2b54f05d748ecb0e31bb2ee7701091c/resource/swift/ocr-arm64
--------------------------------------------------------------------------------
/resource/swift/ocr-x64:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/macacajs/macaca-macos/21a9783ca2b54f05d748ecb0e31bb2ee7701091c/resource/swift/ocr-x64
--------------------------------------------------------------------------------
/resource/swift/readme.md:
--------------------------------------------------------------------------------
1 | ## Build swift project
2 | ```bash
3 | bash ./build.sh
4 | ```
5 |
6 | ## Executable binary create by swift
7 | - OCR
8 | - ocr ability powered by macOS.
9 | - usage: ocr /path/to/img
10 |
11 | - mouse-drag
12 | - mouse drag to (+x,+y) relate to current point
13 | - mouse-drag delay x y
14 |
--------------------------------------------------------------------------------
/resource/swift/src/LeftMouseDragged/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/resource/swift/src/LeftMouseDragged/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.9
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "LeftMouseDragged",
8 | targets: [
9 | // Targets are the basic building blocks of a package, defining a module or a test suite.
10 | // Targets can depend on other targets in this package and products from dependencies.
11 | .executableTarget(
12 | name: "LeftMouseDragged"),
13 | ]
14 | )
15 |
--------------------------------------------------------------------------------
/resource/swift/src/LeftMouseDragged/Sources/main.swift:
--------------------------------------------------------------------------------
1 | // The Swift Programming Language
2 | // https://docs.swift.org/swift-book
3 |
4 | import AppKit
5 | import Cocoa
6 |
7 | func taskDelay(_ millSeconds: Int, _ task: @escaping () -> Void) {
8 | DispatchQueue.main.asyncAfter(deadline: .now() + Double(millSeconds) / 1000.0, execute: task)
9 | }
10 |
11 | func postMouseEvent(button:CGMouseButton, type:CGEventType, point: CGPoint,clickCount:Int64 = 1)
12 | {
13 | let event = createMouseEvent(button: button, type: type, point: point,clickCount:clickCount)
14 | event.post(tap: CGEventTapLocation.cghidEventTap)
15 | }
16 |
17 | func createMouseEvent(button:CGMouseButton, type:CGEventType, point: CGPoint,clickCount:Int64 = 1) -> CGEvent
18 | {
19 | let event : CGEvent = CGEvent(mouseEventSource: CGEventSource.init(stateID: CGEventSourceStateID.privateState), mouseType: type, mouseCursorPosition: point, mouseButton: button)!
20 | event.setIntegerValueField(CGEventField.mouseEventClickState, value: clickCount)
21 | return event
22 | }
23 |
24 | func mouseDragged(point:CGPoint,toPoint:CGPoint,button:CGMouseButton,postDelay:Int){
25 | let toMaxX:Bool = toPoint.x - point.x > 0
26 | let toMaxY:Bool = toPoint.y - point.y > 0
27 |
28 | var tempPointY = point.y
29 | var tempPointX = point.x
30 |
31 | postMouseEvent(button: button, type: button == .left ? .leftMouseDown : .rightMouseDown, point: point,clickCount: 1);
32 |
33 | let blockOperation = BlockOperation()
34 |
35 | blockOperation.addExecutionBlock {
36 | while toMaxY ? (toPoint.y > tempPointY) : (toPoint.y < tempPointY){
37 | Thread.sleep(forTimeInterval: 0.0001 * Double(postDelay))
38 | toMaxY ? (tempPointY += 1) : (tempPointY -= 1)
39 | postMouseEvent(button: button, type: button == .left ? .leftMouseDragged : .rightMouseDragged, point: CGPoint(x: tempPointX, y: tempPointY),clickCount: 1);
40 | }
41 | }
42 | blockOperation.addExecutionBlock {
43 | while toMaxX ? (toPoint.x > tempPointX) : (toPoint.x < tempPointX) {
44 | Thread.sleep(forTimeInterval: 0.0001 * Double(postDelay))
45 | toMaxX ? (tempPointX += 1) : (tempPointX -= 1)
46 | postMouseEvent(button: button, type: button == .left ? .leftMouseDragged : .rightMouseDragged, point: CGPoint(x: tempPointX, y: tempPointY),clickCount: 1);
47 | }
48 |
49 | }
50 | blockOperation.completionBlock = {
51 | postMouseEvent(button: button, type: button == .left ? .leftMouseUp : .rightMouseUp, point: toPoint,clickCount: 1);
52 | }
53 | blockOperation.start()
54 | }
55 |
56 | func printJson(_ data: Any) {
57 | let jsonData = try! JSONSerialization.data(withJSONObject: data)
58 | print(String(data: jsonData, encoding: String.Encoding.utf8) ?? "")
59 | }
60 |
61 | func main(args: [String]) -> Int32 {
62 | guard CommandLine.arguments.count == 6 || CommandLine.arguments.count == 4 else {
63 | fputs(String(format: "usage1: %1$@ postDelay x1 y1 x2 y2, dragged from (x1, y1) to (x1, y2), when move a px, sleep(postDelay)\n", CommandLine.arguments[0]), stderr)
64 | fputs(String(format: "usage2: %1$@ postDelay x y, dragged from mouseLocation to (mouseLocation.x + x, mouseLocation.y + y), when move a px, sleep(postDelay)\n", CommandLine.arguments[0]), stderr)
65 | return 1
66 | }
67 | let postDelay = Int(args[1]) ?? 0
68 | var mouseLoc = NSEvent.mouseLocation
69 |
70 | if (CommandLine.arguments.count == 4) {
71 | mouseLoc.y = NSHeight(NSScreen.screens[0].frame) - mouseLoc.y;
72 | let newLoc = CGPoint(x: mouseLoc.x+CGFloat(Int(args[2]) ?? 0), y: mouseLoc.y+CGFloat(Int(args[3]) ?? 0))
73 | mouseDragged(
74 | point: CGPoint(x: mouseLoc.x, y: mouseLoc.y),
75 | toPoint: newLoc,
76 | button: .left,
77 | postDelay: postDelay
78 | )
79 | printJson([
80 | "success": true,
81 | "from": [ "x1": Int(mouseLoc.x), "y1": Int(mouseLoc.y) ],
82 | "to": [ "x2": Int(newLoc.x), "y2": Int(newLoc.y) ],
83 | ])
84 | return 0
85 | }
86 |
87 | let x1 = Int(args[2]) ?? Int(mouseLoc.x)
88 | let y1 = Int(args[3]) ?? Int(mouseLoc.y)
89 | let x2 = Int(args[4]) ?? Int(mouseLoc.x)
90 | let y2 = Int(args[5]) ?? Int(mouseLoc.y)
91 |
92 | if (x1 != x2 || y1 != y2) {
93 | mouseDragged(
94 | point: CGPoint(x: x1, y: y1),
95 | toPoint: CGPoint(x: x2, y: y2),
96 | button: .left,
97 | postDelay: postDelay
98 | )
99 | }
100 | printJson([
101 | "success": true,
102 | "from": [ "x1": x1, "y1": y1 ],
103 | "to": [ "x2": x2, "y2": y2 ],
104 | ])
105 | return 0
106 | }
107 |
108 | exit(main(args: CommandLine.arguments))
109 |
--------------------------------------------------------------------------------
/resource/swift/src/MacosOcr/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/resource/swift/src/MacosOcr/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.9
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "MacosOcr",
8 | platforms: [
9 | .macOS(.v10_15)
10 | ],
11 | targets: [
12 | // Targets are the basic building blocks of a package, defining a module or a test suite.
13 | // Targets can depend on other targets in this package and products from dependencies.
14 | .executableTarget(
15 | name: "MacosOcr"),
16 | ]
17 | )
18 |
--------------------------------------------------------------------------------
/resource/swift/src/MacosOcr/Sources/main.swift:
--------------------------------------------------------------------------------
1 | // The Swift Programming Language
2 | // https://docs.swift.org/swift-book
3 |
4 | import Cocoa
5 | import Vision
6 | // import ArgumentParser
7 |
8 | // https://developer.apple.com/documentation/vision/vnrecognizetextrequest
9 |
10 | let MODE: VNRequestTextRecognitionLevel = VNRequestTextRecognitionLevel.accurate // or .fast
11 | let USE_LANG_CORRECTION = false
12 | var REVISION:Int
13 | if #available(macOS 13, *) {
14 | REVISION = VNRecognizeTextRequestRevision3
15 | } else if #available(macOS 11, *) {
16 | REVISION = VNRecognizeTextRequestRevision2
17 | } else {
18 | REVISION = VNRecognizeAnimalsRequestRevision1
19 | }
20 |
21 | func main(args: [String]) -> Int32 {
22 | guard CommandLine.arguments.count == 2 else {
23 | fputs(String(format: "usage: %1$@ image\n", CommandLine.arguments[0]), stderr)
24 | return 1
25 | }
26 |
27 | let src = args[1]
28 |
29 | guard let img = NSImage(byReferencingFile: src) else {
30 | fputs("Error: failed to load image '\(src)'\n", stderr)
31 | return 1
32 | }
33 |
34 | guard let imgRef = img.cgImage(forProposedRect: &img.alignmentRect, context: nil, hints: nil) else {
35 | fputs("Error: failed to convert NSImage to CGImage for '\(src)'\n", stderr)
36 | return 1
37 | }
38 |
39 | let request: VNRecognizeTextRequest = VNRecognizeTextRequest { (request, error) in
40 | let observations: [VNRecognizedTextObservation] = request.results as? [VNRecognizedTextObservation] ?? []
41 | var results: [Any] = []
42 | for observation: VNRecognizedTextObservation in observations {
43 | let candidate: VNRecognizedText = observation.topCandidates(1)[0]
44 | let value = [
45 | "word": candidate.string,
46 | "rect": [
47 | "left": Int(Float(observation.topLeft.x) * Float(imgRef.width)),
48 | "top": Int(Float(1 - observation.topLeft.y) * Float(imgRef.height)),
49 | "width": Int(Float(observation.topRight.x - observation.topLeft.x) * Float(imgRef.width)),
50 | "height": Int(Float(observation.topLeft.y - observation.bottomLeft.y) * Float(imgRef.height)),
51 | ],
52 | "confidence": candidate.confidence,
53 | ]
54 | results.append(value)
55 | }
56 | let data = try! JSONSerialization.data(withJSONObject: results)
57 | print(String(data: data, encoding: String.Encoding.utf8) ?? "")
58 | }
59 | request.recognitionLevel = MODE
60 | request.usesLanguageCorrection = USE_LANG_CORRECTION
61 | request.revision = REVISION
62 | request.recognitionLanguages = ["zh-Hans", "en-US"]
63 | //request.minimumTextHeight = 0
64 | //request.customWords = [String]
65 |
66 | try? VNImageRequestHandler(cgImage: imgRef, options: [:]).perform([request])
67 | return 0
68 | }
69 |
70 | exit(main(args: CommandLine.arguments))
71 |
72 | // struct Banner: ParsableCommand {
73 | // static let configuration = CommandConfiguration(
74 | // abstract: "A Swift command-line tool to manage blog post banners",
75 | // subcommands: [Generate.self])
76 |
77 | // init() { }
78 | // }
79 |
80 | // // Banner.main()
81 |
82 | // struct Generate: ParsableCommand {
83 |
84 | // public static let configuration = CommandConfiguration(abstract: "Generate a blog post banner from the given input")
85 |
86 | // @Argument(help: "The title of the blog post")
87 | // private var title: String
88 |
89 | // @Option(name: .shortAndLong, default: nil, help: "The week of the blog post as used in the file name")
90 | // private var week: Int?
91 |
92 | // @Flag(name: .long, help: "Show extra logging for debugging purposes")
93 | // private var verbose: Bool
94 |
95 | // func run() throws {
96 | // if verbose {
97 | // let weekDescription = week.map { "and week \($0)" }
98 | // print("Creating a banner for title \"\(title)\" \(weekDescription ?? "")")
99 | // }
100 | // }
101 | // }
102 |
--------------------------------------------------------------------------------
/src/core/enums.ts:
--------------------------------------------------------------------------------
1 | export enum EDriver {
2 | Swift = 'swift',
3 | RobotJs = 'robotjs',
4 | JXA = 'jxa',
5 | AppleScript = 'applescript',
6 | }
7 |
--------------------------------------------------------------------------------
/src/core/helper.ts:
--------------------------------------------------------------------------------
1 | import os from 'os';
2 | import fs from 'fs';
3 | import npmUpdate from 'npm-update';
4 |
5 | const path = require('path');
6 | const shell = require('shelljs');
7 |
8 | export class Helper {
9 |
10 | /**
11 | * 检测4k屏
12 | */
13 | static isHdpiDisplay(): boolean {
14 | const res = shell.exec('system_profiler SPDisplaysDataType | grep Resolution', { silent: true }).stdout.trim();
15 | if (res) {
16 | // 多个显示器只关注主显示器
17 | const main = res.split('\n')[0].trim();
18 | if (main.includes('Retina')) {
19 | return true;
20 | }
21 | // 一般情况下 4k 高大于等于 2160 像素, 2k达不到这个高度,除非这是个造型很变态的2k显示器...
22 | // 不用宽度做判断(存在宽屏显示器)
23 | const mainY = Number.parseInt(main.split(' x ')[1].split(' ')[0]);
24 | return mainY >= 2160;
25 | }
26 | console.log('无显示器信息');
27 | }
28 |
29 | static async isDeprecated() {
30 | await npmUpdate({
31 | pkg: this.getPkg(),
32 | });
33 | }
34 |
35 | static getPkg() {
36 | let pkg: any = {};
37 | if (__dirname.includes('/dist/src/core')) {
38 | pkg = require('../../../package.json');
39 | } else {
40 | pkg = require('../../package.json');
41 | }
42 | return pkg;
43 | }
44 |
45 | /**
46 | * 获取版本
47 | */
48 | static getPkgVersion(): string {
49 | const pkg = this.getPkg();
50 | return pkg.version;
51 | }
52 |
53 | /**
54 | * 调试日志
55 | * @param items
56 | */
57 | static debug(...items) {
58 | if (process.env.MACACA_MACOS_DEBUG) {
59 | console.log(...items);
60 | }
61 | }
62 |
63 | static getResourcePath(): string {
64 | return path.resolve(__dirname, '../../resource');
65 | }
66 |
67 | /**
68 | * 支持每秒打印时间
69 | * @param ms
70 | * @param tick
71 | */
72 | static async sleep(ms: number, tick = false) {
73 | if (ms > 10000 && tick) {
74 | for (let i = 0; (i * 1000) < ms; i++) {
75 | await this.sleep(1000);
76 | console.log(`${i}s`);
77 | }
78 | } else {
79 | return new Promise(resolve => setTimeout(resolve, ms));
80 | }
81 | }
82 |
83 | /**
84 | * 判断是否为异步函数
85 | * @param func
86 | */
87 | static isAsyncFunc(func: Function) {
88 | return func && typeof func === 'function' && func[Symbol.toStringTag] === 'AsyncFunction';
89 | }
90 |
91 | static hasUnicode(str: string) {
92 | for (let i = 0; i < str.length; i++) {
93 | if (str.charCodeAt(i) > 255) {
94 | return true;
95 | }
96 | }
97 | return false;
98 | }
99 |
100 | static tmpdir() {
101 | // mac 环境需要转换
102 | if (os.platform() === 'darwin') {
103 | return fs.realpathSync(os.tmpdir());
104 | }
105 | return os.tmpdir();
106 | }
107 |
108 | /**
109 | * 等待直到条件方法返回true
110 | * @param func
111 | * @param waitMs
112 | * @param intervalMs
113 | */
114 | static async waitUntil(func: Function, waitMs = 5E3, intervalMs = 1E3) {
115 | const start = Date.now();
116 | const end = start + waitMs;
117 | const isAsyncFunc = this.isAsyncFunc(func);
118 | const fn = () => {
119 | return new Promise(async (resolve, reject) => {
120 | const continuation = (res, rej) => {
121 | const now = Date.now();
122 | if (now < end) {
123 | res(this.sleep(intervalMs).then(fn));
124 | } else {
125 | const funcStr = func.toString().replace(/ +/g, ' ').replace(/\n/g, '');
126 | rej(`Wait For Condition: ${funcStr} timeout ${waitMs} ms`);
127 | }
128 | };
129 | let isOk;
130 | if (isAsyncFunc) {
131 | isOk = await func().catch(() => {
132 | continuation(resolve, reject);
133 | });
134 | } else {
135 | isOk = func();
136 | }
137 | if (isOk) {
138 | resolve(true);
139 | } else {
140 | continuation(resolve, reject);
141 | }
142 | });
143 | };
144 | return fn();
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/src/core/jxa/exec-jxa.ts:
--------------------------------------------------------------------------------
1 | import { execFile } from 'child_process';
2 | import fs from 'fs';
3 | import os from 'os';
4 | import { Helper } from '../helper';
5 |
6 | const shell = require('shelljs');
7 |
8 | const executeInOsa = async (code: string, args: any[]): Promise => {
9 | // 检查并更新lib
10 | if (code.includes('Library(')) {
11 | const scptdDir = `${Helper.getResourcePath()}/applescript/scptd`;
12 | const userLibDir = `${os.homedir()}/Library/Script\ Libraries`;
13 | const versionFile = `${userLibDir}/macaca-macos.txt`;
14 | const version = Helper.getPkgVersion();
15 | if (!fs.existsSync(userLibDir)) {
16 | shell.mkdir('-p', userLibDir);
17 | }
18 | if (fs.existsSync(versionFile)) {
19 | const userVersion = fs.readFileSync(versionFile, 'utf-8').toString().trim();
20 | if (userVersion !== version) {
21 | shell.cp('-R', `${scptdDir}/*`, userLibDir);
22 | shell.rm('-rf', versionFile);
23 | fs.writeFileSync(versionFile, version);
24 | }
25 | } else {
26 | shell.cp('-R', `${scptdDir}/*`, userLibDir);
27 | fs.writeFileSync(versionFile, version);
28 | }
29 | }
30 | const envs: any = {
31 | OSA_ARGS: JSON.stringify(args),
32 | };
33 | return new Promise((resolve, reject) => {
34 | const child = execFile(
35 | '/usr/bin/osascript',
36 | [ '-l', 'JavaScript' ],
37 | {
38 | env: envs,
39 | maxBuffer: 1E8,
40 | },
41 | (err: Error, stdout: any, stderr: any) => {
42 | if (err) {
43 | return reject(err);
44 | }
45 | if (stderr) {
46 | console.error(stderr);
47 | }
48 |
49 | if (!stdout) {
50 | resolve(undefined);
51 | }
52 |
53 | try {
54 | const result = JSON.parse(stdout.toString().trim()).result;
55 | resolve(result);
56 | } catch (errorOutput) {
57 | resolve(stdout.toString().trim());
58 | }
59 | },
60 | );
61 | child.stdin.write(code);
62 | child.stdin.end();
63 | });
64 | };
65 |
66 | const requireFunc = `
67 | ObjC.import('Foundation');
68 | var fm = $.NSFileManager.defaultManager;
69 | var requireHack = function (path) {
70 | var contents = fm.contentsAtPath(path.toString()); // NSData
71 | contents = $.NSString.alloc.initWithDataEncoding(contents, $.NSUTF8StringEncoding);
72 |
73 | var module = {exports: {}};
74 | var exports = module.exports;
75 | eval(ObjC.unwrap(contents));
76 |
77 | return module.exports;
78 | };
79 | `;
80 |
81 | export const execJxa = async (jxaCodeFunction: (...args: any[]) => void, args: any[] = []) => {
82 | const code = `
83 | ObjC.import('stdlib');
84 | ${requireFunc}
85 | var args = JSON.parse($.getenv('OSA_ARGS'));
86 | var fn = (${jxaCodeFunction.toString()});
87 | var out = fn.apply(null, args);
88 | JSON.stringify({ result: out });
89 | `;
90 | return executeInOsa(code, args).catch(e => {
91 | console.warn(e.message);
92 | });
93 | };
94 |
--------------------------------------------------------------------------------
/src/core/jxa/jxaUtil.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
2 | // @ts-nocheck
3 |
4 | // open when coding
5 | // import { Application } from '@jxa/types';
6 | // import '@jxa/global-type';
7 |
8 | import { execJxa } from './exec-jxa';
9 | import { Helper } from '../helper';
10 |
11 | declare global {
12 | function Application(name: string): object;
13 |
14 | function Library(name: string): object;
15 |
16 | function Path(name: string): object;
17 |
18 | function requireHack(name: string): object;
19 |
20 | function delay(delay?: number): void;
21 |
22 | const ObjC: any;
23 | const $: any;
24 | }
25 |
26 | export const jxaUtil = {
27 | /**
28 | * 设置剪贴板内容
29 | * @param str
30 | */
31 | async setClipText(str: string) {
32 | await execJxa((str) => {
33 | const app = Application.currentApplication();
34 | app.includeStandardAdditions = true;
35 | return app.setTheClipboardTo(str || '[NONE]');
36 | }, [ str ]);
37 | },
38 |
39 | async getClipText() {
40 | const res: any = await execJxa(() => {
41 | const app = Application.currentApplication();
42 | app.includeStandardAdditions = true;
43 | return app.theClipboard();
44 | }, []);
45 | Helper.debug('clipText:', res);
46 | return res;
47 | },
48 |
49 | /**
50 | * 键盘按键和组合键
51 | * https://eastmanreference.com/complete-list-of-applescript-key-codes
52 | * @param key
53 | * @param modified command | option | control | shift
54 | */
55 | async keyTap(key: string | number, modified: string[] = []) {
56 | // keyCode
57 | if (typeof key === 'number') {
58 | await execJxa((key, modified) => {
59 | const sys = Application('System Events');
60 | sys.keyCode(key, {
61 | using: modified.map(it => {
62 | return `${it} down`;
63 | }),
64 | });
65 | }, [ key, modified ]);
66 | } else {
67 | await execJxa((key, modified) => {
68 | const sys = Application('System Events');
69 | sys.keystroke(key, {
70 | using: modified.map(it => {
71 | return `${it} down`;
72 | }),
73 | });
74 | }, [ key, modified ]);
75 | }
76 | },
77 |
78 | /**
79 | * 字符串输入
80 | * - 不支持Unicode字符
81 | * @param str
82 | * @param delay
83 | */
84 | async typeString(str, delay = false) {
85 | if (delay) {
86 | await execJxa((str) => {
87 | const sys = Application('System Events');
88 | for (let i = 0; i < str.length; i++) {
89 | sys.keystroke(str[i]);
90 | delay(0.1);
91 | }
92 | }, [ str ]);
93 | } else {
94 | await execJxa((str) => {
95 | const sys = Application('System Events');
96 | sys.keystroke(str);
97 | }, [ str ]);
98 | }
99 | },
100 |
101 | /**
102 | * 关闭app
103 | */
104 | async safeQuitApp(appName: string) {
105 | await execJxa((appName) => {
106 | const app = Application(appName);
107 | if (app.running()) {
108 | app.quit();
109 | }
110 | }, [ appName ]);
111 | },
112 |
113 | /**
114 | * 窗口重定位(默认左上角)
115 | * @param opts
116 | */
117 | async resizePosition(opts: {
118 | name: string;
119 | topLeftX?: number;
120 | topLeftY?: number;
121 | width?: number;
122 | height?: number;
123 | }) {
124 | opts.topLeftX = opts.topLeftX || 0;
125 | opts.topLeftY = opts.topLeftY || 0;
126 | if (opts.width && opts.height) {
127 | await execJxa((opts) => {
128 | const window = Library('window');
129 | const app = Application(opts.name);
130 | if (app.running()) {
131 | app.activate();
132 | window.sizePosition(opts.name, opts.topLeftX, opts.topLeftY, opts.width, opts.height);
133 | }
134 | }, [ opts ]);
135 | } else {
136 | // 仅移动位置
137 | await execJxa((opts) => {
138 | const window = Library('window');
139 | const app = Application(opts.name);
140 | if (app.running()) {
141 | app.activate();
142 | window.setPosition(opts.name, opts.topLeftX, opts.topLeftY);
143 | }
144 | }, [ opts ]);
145 | }
146 | },
147 |
148 | /**
149 | * 获取当前系统用户信息
150 | */
151 | async getUserName() {
152 | const res: any = await execJxa(() => {
153 | const sys = Application('System Events');
154 | return sys.currentUser().name();
155 | }, []);
156 | return res;
157 | },
158 |
159 | /**
160 | * app运行状态
161 | * @param appName
162 | */
163 | async isAppRunning(appName: string) {
164 | const res: boolean = await execJxa((appName) => {
165 | return Application(appName).running();
166 | }, [ appName ]);
167 | return res;
168 | },
169 |
170 | /**
171 | * 对话框, 接受用户输入并返回
172 | */
173 | async prompt(message: string): Promise {
174 | return execJxa((msg) => {
175 | const app = Application.currentApplication();
176 | app.includeStandardAdditions = true;
177 | try {
178 | return app.displayDialog(msg, { defaultAnswer: '' }).textReturned;
179 | } catch (e) {
180 | return null;
181 | }
182 | }, [ message ]);
183 | },
184 |
185 | /**
186 | * 告警
187 | * @param title
188 | * @param msg
189 | * @param type
190 | */
191 | async alert(title: string, msg: string, type = 'info'): Promise {
192 | let icon;
193 | if (type === 'warn') {
194 | icon = '⚠️';
195 | } else if (type === 'error') {
196 | icon = '❌';
197 | } else {
198 | icon = '🤖';
199 | }
200 | const message = `${icon} ${type}: ${msg}`;
201 | return execJxa((title, msg) => {
202 | const app = Application.currentApplication();
203 | app.includeStandardAdditions = true;
204 | app.displayAlert(title, { message: msg });
205 | }, [ title, message ]);
206 | },
207 |
208 | async confirm(msg: string): Promise {
209 | return execJxa((msg) => {
210 | const app = Application.currentApplication();
211 | app.includeStandardAdditions = true;
212 | try {
213 | app.displayDialog(msg);
214 | return true;
215 | } catch (e) {
216 | return false;
217 | }
218 | }, [ msg ]);
219 | },
220 |
221 | /**
222 | * 浏览器可以
223 | * ⚠️ electron app 似乎获取不到窗口ID
224 | * @param appName 'Google Chrome' | 'Safari' | 'Firefox'
225 | */
226 | async getWindowIdByAppName(appName: string) {
227 | const res: any = await execJxa((appName) => {
228 | const app = Application(appName);
229 | app.includeStandardAdditions = true;
230 | if (app.running()) {
231 | app.activate();
232 | if (app.windows[0]) {
233 | const window = app.windows[0];
234 | return window.id();
235 | }
236 | return app.id();
237 | }
238 | }, [ appName ]);
239 | Helper.debug(res);
240 | return res;
241 | },
242 | /**
243 | * 激活聚焦
244 | * @param appName
245 | */
246 | async focusApp(appName: string) {
247 | await execJxa((appName) => {
248 | const app = Application(appName);
249 | app.includeStandardAdditions = true;
250 | if (app.running()) {
251 | app.activate();
252 | }
253 | }, [ appName ]);
254 | },
255 |
256 | /**
257 | * 鼠标操作(默认当前位置左键)
258 | * - 不稳定,会失效
259 | */
260 | async click(opts: {
261 | x: number;
262 | y: number;
263 | r?: boolean;
264 | } = {}) {
265 | const mouseLib = `${Helper.getResourcePath()}/javascript/mouse.js`;
266 | await execJxa((lib, opts) => {
267 | const mouse = requireHack(lib);
268 | mouse.click(opts);
269 | }, [ mouseLib, opts ]);
270 | },
271 |
272 | // FIXME not work
273 | async drag(x: number, y: number) {
274 | const mouseLib = `${Helper.getResourcePath()}/javascript/mouse.js`;
275 | await execJxa((lib, x, y) => {
276 | const mouse = requireHack(lib);
277 | mouse.drag(x, y);
278 | }, [ mouseLib, x, y ]);
279 | },
280 |
281 | async asSafeActivate(appName: string) {
282 | await execJxa((appName) => {
283 | const win = Library('window');
284 | return win.safeActivate(appName);
285 | }, [ appName ]);
286 | },
287 | };
288 |
--------------------------------------------------------------------------------
/src/core/jxa/osaUtil.ts:
--------------------------------------------------------------------------------
1 | import { Helper } from '../helper';
2 |
3 | const applescript = require('applescript');
4 | const shell = require('shelljs');
5 | const scptDir = `${Helper.getResourcePath()}/applescript/scpt`;
6 |
7 | // 直接执行 AppleScript 脚本/脚本包
8 | export const osaUtil = {
9 |
10 | async execAppleScriptStr(funcStr: string): Promise {
11 | return new Promise((resolve, reject) => {
12 | applescript.execString(funcStr, (err, rtn) => {
13 | if (err) {
14 | console.error(err);
15 | reject('Something went wrong!');
16 | }
17 | resolve(rtn);
18 | });
19 | });
20 | },
21 |
22 | /**
23 | * 文件要求以 applescript 结尾的源码
24 | * @param file
25 | * @param args
26 | */
27 | async execAppleScriptFile(file: string, args = []): Promise {
28 | if (!file.endsWith('.applescript')) {
29 | console.error('只支持applescript格式文件');
30 | return;
31 | }
32 | return new Promise((resolve, reject) => {
33 | applescript.execFile(file, args, (err, rtn) => {
34 | if (err) {
35 | console.error(err);
36 | reject('Something went wrong!');
37 | }
38 | resolve(rtn);
39 | });
40 | });
41 | },
42 |
43 | /**
44 | * 执行scpt文件
45 | */
46 | execScptFile(file: string, args = []) {
47 | if (!file.endsWith('.scpt')) {
48 | console.error('只支持scpt格式文件');
49 | return;
50 | }
51 | shell.chmod('+x', file);
52 | const cmd = `osascript '${file}' ${args.join(' ')} -ss`;
53 | Helper.debug(cmd);
54 | const res = shell.exec(cmd, { silent: true }).stdout;
55 | Helper.debug(res);
56 | return res;
57 | },
58 |
59 | /**
60 | * applescript 实现
61 | * jxa运行applescript lib会有问题 只能通过shell执行
62 | * - 适用原生MacOS的应用(实际点击的是坐标所在的UI元素),不适用Electron
63 | * - FIXME 似乎只能点击到到执行脚本的应用窗口,否则卡死
64 | * @param opts
65 | */
66 | click(opts: {
67 | x: number;
68 | y: number;
69 | }) {
70 | Helper.debug(opts);
71 | this.execScptFile(`${scptDir}/mouseClick.scpt`, [ opts.x, opts.y ]);
72 | },
73 |
74 | /**
75 | * 隐藏所有APP(显示桌面)
76 | */
77 | async hideAllApp() {
78 | await this.execAppleScriptStr(`
79 | tell application "Finder"
80 | set visible of every process whose visible is true and name is not "Finder" to false
81 | set the collapsed of windows to true
82 | end tell
83 | `);
84 | },
85 |
86 | /**
87 | * 启动app
88 | * @param name
89 | */
90 | async safeLaunchApp(name: string) {
91 | await this.execAppleScriptStr(`
92 | set appName to "${name}"
93 | set startIt to false
94 | tell application "System Events"
95 | if not (exists process appName) then
96 | set startIt to true
97 | else if frontmost of process appName then
98 | set visible of process appName to false
99 | else
100 | set frontmost of process appName to true
101 | end if
102 | end tell
103 | if startIt then
104 | tell application appName to activate
105 | end if
106 | `);
107 | },
108 |
109 | async focusApp(name: string) {
110 | await this.execAppleScriptStr(`
111 | tell application "${name}"
112 | reopen
113 | activate
114 | end tell
115 | `);
116 | },
117 |
118 | /**
119 | * 获取所有app的位置和长宽
120 | * - 不可见的窗口无法查到
121 | * - 一个app可能会有多个窗口
122 | */
123 | async getAllAppSizePosition() {
124 | const res = await this.execAppleScriptStr(`
125 | tell application "System Events"
126 | set _P to a reference to (processes whose background only = false)
127 | set _W to a reference to windows of _P
128 | set res to [_P's name, _W's size, _W's position]
129 | return res
130 | end tell
131 | `);
132 | const result = [];
133 | const names = res[0];
134 | const sizes = res[1];
135 | const positions = res[2];
136 | for (let i = 0; i < names.length; i++) {
137 | result.push({
138 | name: names[i],
139 | size: sizes[i],
140 | position: positions[i],
141 | });
142 | }
143 | return result;
144 | },
145 | };
146 |
--------------------------------------------------------------------------------
/src/core/mixin.ts:
--------------------------------------------------------------------------------
1 | export default function applyMixins(derivedCtor: any, constructors: any[]) {
2 | constructors.forEach((baseCtor) => {
3 | Object.getOwnPropertyNames(baseCtor.prototype).forEach((name) => {
4 | Object.defineProperty(
5 | derivedCtor.prototype,
6 | name,
7 | Object.getOwnPropertyDescriptor(baseCtor.prototype, name) || Object.create(null),
8 | );
9 | });
10 | });
11 | }
12 |
--------------------------------------------------------------------------------
/src/driver/app.ts:
--------------------------------------------------------------------------------
1 | import shell from 'shelljs';
2 | import { Helper } from '../core/helper';
3 | import { EDriver } from '../core/enums';
4 | import { osaUtil } from '../core/jxa/osaUtil';
5 | import { jxaUtil } from '../core/jxa/jxaUtil';
6 |
7 | export default class AppDriver {
8 | async hideAllApp() {
9 | await osaUtil.hideAllApp();
10 | }
11 |
12 | async startApp(appNameOrFile) {
13 | if (appNameOrFile.startsWith('/')) {
14 | return shell.exec(`open ${appNameOrFile}`, { silent: true });
15 | }
16 | return await osaUtil.safeLaunchApp(appNameOrFile);
17 | }
18 |
19 | async focusApp(name: string, opts: {
20 | driver?: EDriver;
21 | } = {}) {
22 | const {
23 | driver = EDriver.AppleScript,
24 | } = opts;
25 | if (driver === EDriver.AppleScript) {
26 | return osaUtil.focusApp(name);
27 | } else if (driver === EDriver.JXA) {
28 | return jxaUtil.focusApp(name);
29 | }
30 | }
31 |
32 | async isAppRunning(name: string) {
33 | return jxaUtil.isAppRunning(name);
34 | }
35 |
36 | /**
37 | * 关闭app
38 | */
39 | async safeQuitAppByName(name: string) {
40 | if (await this.isAppRunning(name)) {
41 | await jxaUtil.safeQuitApp(name);
42 | // 等待退出完毕
43 | await Helper.waitUntil(async () => {
44 | return !(await this.isAppRunning(name));
45 | });
46 | }
47 | }
48 |
49 | /**
50 | * 仅返回第一个窗口
51 | * @param name
52 | */
53 | async getAppSizePosition(name: string) {
54 | const ress = await osaUtil.getAllAppSizePosition();
55 | const res = ress.find(it => {
56 | return it.name === name;
57 | });
58 | if (res && res.position.length > 0) {
59 | return {
60 | topLeftX: res.position[0][0],
61 | topLeftY: res.position[0][1],
62 | width: res.size[0][0],
63 | height: res.size[0][1],
64 | };
65 | }
66 | }
67 |
68 | /**
69 | * 设置app窗口位置
70 | * @param opts
71 | */
72 | async resizePosition(opts: {
73 | name: string;
74 | topLeftX?: number;
75 | topLeftY?: number;
76 | width?: number;
77 | height?: number;
78 | }) {
79 | // app 窗口存在
80 | await Helper.waitUntil(async () => {
81 | return this.getAppSizePosition(opts.name);
82 | }, 10E3);
83 | await jxaUtil.resizePosition(opts);
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/driver/clipboard.ts:
--------------------------------------------------------------------------------
1 | import { jxaUtil } from '../core/jxa/jxaUtil';
2 |
3 | export default class ClipboardDriver {
4 | /**
5 | * 获取剪切板的文本
6 | */
7 | async getClipText(): Promise {
8 | return jxaUtil.getClipText();
9 | }
10 |
11 | /**
12 | * 设置文本到剪切板
13 | * @param str string
14 | */
15 | async setClipText(str: string) {
16 | return jxaUtil.setClipText(str);
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/driver/keyboard.ts:
--------------------------------------------------------------------------------
1 | import robot from 'robotjs';
2 | import { jxaUtil } from '../core/jxa/jxaUtil';
3 | import { Helper } from '../core/helper';
4 |
5 | export default class KeyboardDriver {
6 | /**
7 | * 字符串输入
8 | * @param str
9 | * @param delay
10 | * @param robotJs
11 | */
12 | async keyboardTypeString(str: string, delay = false, robotJs = false) {
13 | const hasUnicode = Helper.hasUnicode(str);
14 | if (robotJs) {
15 | // 已知问题
16 | // ⚠️ robotJs 在 keyTap 执行之后会不稳定
17 | if (!hasUnicode && delay) {
18 | // delay 不支持中文delay输入
19 | await robot.typeStringDelayed(str, 5E3);
20 | } else {
21 | // 支持中文
22 | robot.typeString(str);
23 | }
24 | } else {
25 | if (hasUnicode) {
26 | const temp = await jxaUtil.getClipText();
27 | await jxaUtil.setClipText(str);
28 | await this.keyboardTap('v', [ 'command' ]);
29 | await this.keyboardTap('right');
30 | await jxaUtil.setClipText(temp); // 恢复
31 | } else {
32 | await jxaUtil.typeString(str, delay);
33 | }
34 | }
35 | }
36 |
37 | /**
38 | * @param key
39 | * @param modified
40 | */
41 | async keyboardTap(key: string, modified: string[] = []) {
42 | robot.keyTap(key, modified);
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/driver/mouse.ts:
--------------------------------------------------------------------------------
1 | import robot from 'robotjs';
2 | import shell from 'shelljs';
3 | import { Helper } from '../core/helper';
4 | import { EDriver } from '../core/enums';
5 | import { osaUtil } from '../core/jxa/osaUtil';
6 | import { jxaUtil } from '../core/jxa/jxaUtil';
7 | import ScreenDriver from './screen';
8 | import os from 'os';
9 | import assert from 'assert';
10 | import fs from 'fs';
11 |
12 | export default class MouseDriver {
13 | mouseMoveTo(x: number, y: number) {
14 | robot.moveMouseSmooth(x, y);
15 | }
16 |
17 | /**
18 | * 鼠标点击 当前鼠标所在位置
19 | * @param opts
20 | */
21 | mouseClick(opts: {
22 | button?: string; // left | middle | right
23 | doubleClick?: boolean; // for robotJs only
24 | driver?: EDriver;
25 | } = {}) {
26 | const {
27 | driver = EDriver.RobotJs,
28 | button = 'left',
29 | doubleClick = false,
30 | } = opts;
31 | if (driver === EDriver.AppleScript) {
32 | const pos = this.mouseGetPos();
33 | Helper.debug('click', pos);
34 | return osaUtil.click(pos);
35 | }
36 | robot.mouseClick(button, doubleClick);
37 | }
38 |
39 | /**
40 | * 点击屏幕/目标区域的文案
41 | * 依赖 ocr
42 | * @param opts
43 | */
44 | async mouseClickText(opts: {
45 | text: string; // 目标文案
46 | index?: number; // 重复项指针
47 | rectangle?: string; // 截图目标区域 通过矩形框 x,y,width,height 默认全屏
48 | clickOpts?: any;
49 | shiftX?: number; // 偏移量
50 | shiftY?: number;
51 | }) {
52 | const {
53 | text, index, rectangle,
54 | clickOpts = {},
55 | shiftX = 0,
56 | shiftY = 0,
57 | } = opts;
58 | const res = await new ScreenDriver().getTextsPosition({
59 | texts: [ text ],
60 | index,
61 | rectangle,
62 | });
63 | if (res.length) {
64 | let { x, y } = res[0];
65 | // 绝对位置
66 | if (rectangle) {
67 | x = Number.parseInt(rectangle.split(',')[0]) + x;
68 | y = Number.parseInt(rectangle.split(',')[1]) + y;
69 | }
70 | // 偏移
71 | x = x + shiftX;
72 | y = y + shiftY;
73 | this.mouseMoveTo(x, y);
74 | this.mouseClick(clickOpts);
75 | return true;
76 | }
77 | }
78 |
79 | /**
80 | * 从当前位置拖拽到目标位置
81 | * @param x
82 | * @param y
83 | * @param opts
84 | */
85 | async mouseDrag(x: number, y: number, opts: {
86 | driver?: EDriver;
87 | } = {}) {
88 | if (opts.driver === EDriver.JXA) {
89 | await jxaUtil.drag(x, y);
90 | } else if (opts.driver === EDriver.RobotJs) {
91 | // Robotjs 存在无法拖动app窗口的问题
92 | robot.mouseToggle('down');
93 | robot.dragMouse(x, y);
94 | robot.mouseToggle('up');
95 | }
96 | // default swift
97 | const curr_pos = this.mouseGetPos();
98 | const cmdFile = `${Helper.getResourcePath()}/swift/mouse-drag-${os.arch()}`;
99 | assert(fs.existsSync(cmdFile), `不支持的架构: ${os.arch()}`);
100 | shell.exec(`${cmdFile} 10 ${curr_pos.x} ${curr_pos.y} ${x} ${y}`, { silent: true });
101 | }
102 |
103 | mouseGetPos() {
104 | return robot.getMousePos();
105 | }
106 |
107 | mouseScroll(x: number, y: number) {
108 | robot.scrollMouse(x, y);
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/driver/network.ts:
--------------------------------------------------------------------------------
1 | import shell from 'shelljs';
2 | import { Helper } from '../core/helper';
3 |
4 | export default class NetworkDriver {
5 | wifiDeviceName;
6 | /**
7 | * 获取wifi设备名
8 | * macos 一般为 en0 或 en1
9 | */
10 | getWifiDeviceName(): string {
11 | if (this.wifiDeviceName) {
12 | return this.wifiDeviceName;
13 | }
14 | const name = shell.exec('networksetup -listnetworkserviceorder | sed -n \'/Wi-Fi/s|.*Device: \\(.*\\)).*|\\1|p\'', { silent: true }).stdout;
15 | Helper.debug(name);
16 | this.wifiDeviceName = name.trim();
17 | return this.wifiDeviceName;
18 | }
19 |
20 | wifiTurnOff() {
21 | const cmd = `networksetup -setairportpower ${this.getWifiDeviceName()} off`;
22 | shell.exec(cmd, { silent: true });
23 | }
24 |
25 | wifiTurnOn() {
26 | const cmd = `networksetup -setairportpower ${this.getWifiDeviceName()} on`;
27 | shell.exec(cmd, { silent: true });
28 | }
29 |
30 | /**
31 | * wifi 状态获取
32 | */
33 | isWifiOn(): boolean {
34 | // Wi-Fi Power (en0): On
35 | const cmd = `networksetup -getairportpower ${this.getWifiDeviceName()}`;
36 | const status = shell.exec(cmd, { silent: true }).stdout;
37 | Helper.debug(`wifi status: ${status.trim()}`);
38 | return !!status.includes('On');
39 | }
40 |
41 | /**
42 | * 转换开关状态
43 | */
44 | wifiToggle() {
45 | const name = this.getWifiDeviceName();
46 | const cmd = `networksetup -getairportpower ${name} | grep "On" && networksetup -setairportpower ${name} off || networksetup -setairportpower ${name} on`;
47 | shell.exec(cmd, { silent: true });
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/driver/screen.ts:
--------------------------------------------------------------------------------
1 | import shell from 'shelljs';
2 | import robot from 'robotjs';
3 | import fs from 'fs';
4 | import { Helper } from '../core/helper';
5 | import os from 'os';
6 | import assert from 'assert';
7 |
8 | export default class ScreenDriver {
9 |
10 | hidpi = Helper.isHdpiDisplay();
11 |
12 | /**
13 | * 暴露ocr方法,支持通过重写使用三方能力替代
14 | */
15 | async fileOcr(imgFile: string): Promise<{
16 | rect: {
17 | left: number;
18 | top: number;
19 | height: number;
20 | width: number;
21 | };
22 | word: string;
23 | }[]> {
24 | const cmdFile = `${Helper.getResourcePath()}/swift/ocr-${os.arch()}`;
25 | assert(fs.existsSync(cmdFile), `不支持的架构: ${os.arch()}`);
26 | const resStr = shell.exec(`${cmdFile} ${imgFile}`, { silent: true }).stdout;
27 | return JSON.parse(resStr);
28 | }
29 |
30 | /**
31 | * 使用系统ocr能力
32 | */
33 | async screenOcr(opts: {
34 | picFile?: string;
35 | rectangle?: string; // 通过矩形框 x,y,width,height
36 | count?: number; // 支持多次结果合并返回,增强识别率
37 | } = {}) {
38 | const { picFile, rectangle, count = 1 } = opts;
39 | const saveFile = picFile || `${Helper.tmpdir()}/${Date.now()}.png`;
40 | if (!fs.existsSync(saveFile)) {
41 | this.screenShot(saveFile, { rectangle });
42 | }
43 | const ocrRes = [];
44 | for (let i = 0; i < count; i++) {
45 | const res = await this.fileOcr(saveFile);
46 | ocrRes.push(...res);
47 | }
48 | return {
49 | imgFile: saveFile,
50 | ocrRes,
51 | };
52 | }
53 |
54 | /**
55 | * 检查文案存在
56 | */
57 | async checkTextExist(opts: {
58 | text: string;
59 | picFile?: string; // 可直接指定图片
60 | rectangle?: string; // 截图目标区域 通过矩形框 x,y,width,height 默认全屏
61 | }): Promise {
62 | const { text, picFile, rectangle } = opts;
63 | const res = await this.getTextsPosition({
64 | texts: [ text ],
65 | picFile, rectangle,
66 | });
67 | return !!res.length;
68 | }
69 |
70 | /**
71 | * 获取文案在截图区域的位置
72 | * @param opts
73 | */
74 | async getTextsPosition(opts: {
75 | texts: string[]; // 目标文案
76 | index?: number; // 重复项指针
77 | contains?: boolean; // 包含即可
78 | picFile?: string; // 可直接指定图片
79 | rectangle?: string; // 截图目标区域 通过矩形框 x,y,width,height 默认全屏
80 | }) {
81 | const {
82 | texts, rectangle, picFile,
83 | index = 0,
84 | contains = true,
85 | } = opts;
86 | // 获取文案位置
87 | const { ocrRes } = await this.screenOcr({ picFile, rectangle });
88 | const resultList = [];
89 | // 找多个目标
90 | for (const text of texts) {
91 | const hitItems = ocrRes.filter(it => {
92 | return contains ? it.word.includes(text) : it.word.trim() === text;
93 | });
94 | // 支持倒数
95 | const idx = index < 0 ? hitItems.length + index : index;
96 | const hitItem = hitItems[idx];
97 | // 相对位置
98 | if (hitItem) {
99 | const { left, top, height, width } = hitItem.rect;
100 | let xx = left + width / 2;
101 | let yy = top + height / 2;
102 | if (this.hidpi) {
103 | xx = xx / 2;
104 | yy = yy / 2;
105 | }
106 | resultList.push({ x: Math.floor(xx), y: Math.floor(yy), word: hitItem.word });
107 | }
108 | }
109 | return resultList;
110 | }
111 |
112 | /**
113 | * 含头部的系统状态栏
114 | */
115 | screenGetSize(): {
116 | width: number;
117 | height: number;
118 | } {
119 | return robot.getScreenSize();
120 | }
121 |
122 | screenCaptureBuffer(area: {
123 | topLeftX: number;
124 | topLeftY: number;
125 | width: number;
126 | height: number;
127 | }) {
128 | const img = robot.screen.capture(area.topLeftX, area.topLeftY, area.width, area.height);
129 | return img.image;
130 | }
131 |
132 | /**
133 | * 截图
134 | * @param picFile
135 | * @param opts
136 | */
137 | screenShot(picFile: string, opts: {
138 | rectangle?: string; // 通过矩形框 x,y,width,height
139 | } = {}) {
140 | const { rectangle } = opts;
141 | let cmd = 'screencapture -x -r';
142 | if (rectangle) {
143 | cmd += ` -R ${rectangle}`;
144 | }
145 | cmd += ` ${picFile}`;
146 | shell.exec(cmd);
147 | return picFile;
148 | }
149 |
150 | getPixelColor(x, y) {
151 | return robot.getPixelColor(x, y);
152 | }
153 | }
154 |
--------------------------------------------------------------------------------
/src/driver/video.ts:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import shell from 'shelljs';
3 | import { Helper } from '../core/helper';
4 | import mixin from '../core/mixin';
5 | import KeyboardDriver from './keyboard';
6 |
7 | class VideoDriver extends KeyboardDriver {
8 | recordingVideoFile: string;
9 | /**
10 | * 开始录像,返回mov文件路径
11 | * @param opts
12 | */
13 | startVideo(opts: {
14 | movFile?: string; // 指定保存的mov文件
15 | rectangle?: string; // 通过矩形框 x,y,width,height
16 | seconds?: number; // 指定时长
17 | } = {}) {
18 | if (this.recordingVideoFile) {
19 | console.error('存在录制中的录像');
20 | return;
21 | }
22 | const { rectangle, seconds, movFile } = opts;
23 | // 静音 录像 显示点击
24 | let args = [
25 | '-x',
26 | '-r',
27 | '-v',
28 | '-k',
29 | ];
30 | if (rectangle) {
31 | args = args.concat([
32 | '-R',
33 | rectangle,
34 | ]);
35 | }
36 | if (seconds) {
37 | args = args.concat([
38 | '-V',
39 | `${seconds}`,
40 | ]);
41 | }
42 | const saveFile = movFile || `${Helper.tmpdir()}/${Date.now()}.mov`;
43 | args.push(saveFile);
44 | const cmd = `screencapture ${args.join(' ')}`;
45 | shell.exec(cmd, { silent: true, async: true });
46 | this.recordingVideoFile = saveFile;
47 | }
48 |
49 | /**
50 | * 结束当前录像
51 | * @param destFile
52 | */
53 | async saveVideo(destFile?: string): Promise {
54 | if (
55 | destFile
56 | && !destFile.endsWith('.mov')
57 | && !destFile.endsWith('.mp4')
58 | ) {
59 | console.error('仅支持mov和mp4格式');
60 | return;
61 | }
62 | // 结束录制
63 | const movFile = this.recordingVideoFile;
64 | this.recordingVideoFile = null;
65 | if (!movFile) {
66 | console.error('未开始录像');
67 | return;
68 | }
69 | // 触发 screencapture 结束录像并保存录像文件
70 | await this.keyboardTap('escape', [ 'command', 'control' ]);
71 | await Helper.waitUntil(() => {
72 | return fs.existsSync(movFile);
73 | });
74 | // 完成 screencapture 程序退出
75 | await this.keyboardTap('escape');
76 | if (destFile) {
77 | // ffmpeg 转换
78 | if (destFile.endsWith('.mp4')) {
79 | const cmd = `ffmpeg -i ${movFile} -vcodec h264 -an -crf 20 -preset ultrafast -strict -2 -y ${destFile}`;
80 | shell.exec(cmd, { silent: true });
81 | } else {
82 | shell.cp(movFile, destFile);
83 | }
84 | return destFile;
85 | }
86 | return movFile;
87 | }
88 | }
89 |
90 | mixin(VideoDriver, [
91 | KeyboardDriver,
92 | ]);
93 |
94 | export default VideoDriver;
95 |
--------------------------------------------------------------------------------
/src/macaca-macos.ts:
--------------------------------------------------------------------------------
1 | import mixin from './core/mixin';
2 | import AppDriver from './driver/app';
3 | import MouseDriver from './driver/mouse';
4 | import KeyboardDriver from './driver/keyboard';
5 | import ClipboardDriver from './driver/clipboard';
6 | import VideoDriver from './driver/video';
7 | import ScreenDriver from './driver/screen';
8 | import { jxaUtil } from './core/jxa/jxaUtil';
9 | import { osaUtil } from './core/jxa/osaUtil';
10 | import NetworkDriver from './driver/network';
11 |
12 | class MacacaMacOS {
13 | static jxaUtil = jxaUtil;
14 | static osaUtil = osaUtil;
15 | }
16 |
17 | interface MacacaMacOS extends AppDriver, MouseDriver, KeyboardDriver, ClipboardDriver, VideoDriver, ScreenDriver, NetworkDriver {}
18 |
19 | mixin(MacacaMacOS, [
20 | AppDriver,
21 | MouseDriver,
22 | KeyboardDriver,
23 | ClipboardDriver,
24 | VideoDriver,
25 | ScreenDriver,
26 | NetworkDriver,
27 | ]);
28 |
29 | export default MacacaMacOS;
30 |
--------------------------------------------------------------------------------
/test/jxaUtil.test.ts:
--------------------------------------------------------------------------------
1 | import { jxaUtil } from '../src/core/jxa/jxaUtil';
2 | import MacacaMacOS from '../src/macaca-macos';
3 | import assert from 'assert';
4 |
5 | describe.skip('jxaUtil unit testing', function() {
6 |
7 | it('focusApp should be ok', async () => {
8 | await jxaUtil.focusApp('Notes');
9 | const win = await new MacacaMacOS().getAppSizePosition('Notes');
10 | assert(win);
11 | });
12 |
13 | });
14 |
--------------------------------------------------------------------------------
/test/macaca-macos.test.ts:
--------------------------------------------------------------------------------
1 | import MacacaMacOS from '../src/macaca-macos';
2 | import { Helper } from '../src/core/helper';
3 | import { EDriver } from '../src/core/enums';
4 | import assert from 'assert';
5 |
6 | describe('macaca-macos unit testing', function() {
7 | this.timeout(0);
8 | process.env.MACACA_MACOS_DEBUG = 'true';
9 | const driver = new MacacaMacOS();
10 | let res: any;
11 |
12 | it.skip('isAppRunning should be ok', async () => {
13 | this.timeout(0);
14 | assert(driver);
15 | await driver.startApp('/System/Applications/Notes.app');
16 | res = await driver.isAppRunning('Notes');
17 | assert(res);
18 | });
19 |
20 | it('mouseGetPos should be ok', async () => {
21 | this.timeout(0);
22 | const res = driver.mouseGetPos();
23 | assert(res);
24 | });
25 |
26 | // 测试
27 | it.skip('mouseClickText should be ok', async () => {
28 | this.timeout(0);
29 | const res = driver.mouseClickText({
30 | text: '测试',
31 | shiftX: 100,
32 | });
33 | assert(res);
34 | });
35 |
36 | it.skip('checkTextExist should be ok', async () => {
37 | this.timeout(0);
38 | const res = driver.checkTextExist({
39 | text: '文案存在',
40 | });
41 | assert(res);
42 | });
43 |
44 | it.skip('overwrite should be ok', async () => {
45 | this.timeout(0);
46 | driver.fileOcr = async (imgFile) => {
47 | console.log(imgFile);
48 | return [
49 | {
50 | word: '哈哈',
51 | rect: {
52 | left: 0,
53 | top: 1,
54 | height: 100,
55 | width: 100,
56 | },
57 | },
58 | ];
59 | };
60 | const res = driver.checkTextExist({
61 | text: '哈哈',
62 | });
63 | assert(res);
64 | });
65 |
66 | it.skip('mouse drag should be ok', async function() {
67 | this.timeout(0);
68 | await Helper.sleep(3E3);
69 | await driver.mouseDrag(100, 100);
70 | const res = driver.mouseGetPos();
71 | console.log(res);
72 | assert(res.x === 100 && res.y === 100);
73 | });
74 |
75 | it.skip('screen ocr should be ok', async function() {
76 | this.timeout(0);
77 | const res = await driver.screenOcr();
78 | console.log(JSON.stringify(res, null, 2));
79 | assert(res.ocrRes);
80 | });
81 |
82 | it.skip('AppleScript mouseClick should be ok', async () => {
83 | this.timeout(0);
84 | driver.mouseClick({
85 | driver: EDriver.AppleScript,
86 | });
87 | });
88 |
89 | it('Clipboard actions should be ok', async () => {
90 | this.timeout(0);
91 | const str = 'Hello world.';
92 | await driver.setClipText(str);
93 | const res = await driver.getClipText();
94 | assert.equal(res, str, '剪贴板内容不符合预期');
95 | });
96 |
97 | it('video should be ok', async function() {
98 | this.timeout(0);
99 | driver.startVideo({ rectangle: '0,0,400,600' });
100 | await Helper.sleep(10E3);
101 | const mov = await driver.saveVideo();
102 | console.log(mov);
103 | assert(mov);
104 | });
105 |
106 | it('focusApp should be ok', async function() {
107 | this.timeout(0);
108 | await driver.focusApp('Notes');
109 | console.log('end');
110 | });
111 |
112 | describe.skip('Network driver test', function() {
113 | this.timeout(0);
114 | it('wifi device name should work', async function() {
115 | this.timeout(0);
116 | const name = driver.getWifiDeviceName();
117 | assert(name, 'wifi设备查询异常');
118 | });
119 |
120 | it('wifi device turn on should work', async function() {
121 | this.timeout(0);
122 | driver.wifiTurnOn();
123 | const isWifiOn = driver.isWifiOn();
124 | assert(isWifiOn, 'wifi状态检查异常');
125 | });
126 |
127 | });
128 | });
129 |
--------------------------------------------------------------------------------
/test/mocha.opts:
--------------------------------------------------------------------------------
1 | --reporter spec
2 |
--------------------------------------------------------------------------------
/test/osaUtil.test.ts:
--------------------------------------------------------------------------------
1 | import { osaUtil } from '../src/core/jxa/osaUtil';
2 | import MacacaMacOS from '../src/macaca-macos';
3 | import assert from 'assert';
4 |
5 | describe('osaUtil unit testing', function() {
6 |
7 | it('getAllAppSizePosition should be ok', async () => {
8 | const res = await osaUtil.getAllAppSizePosition();
9 | console.log(res);
10 | assert(res);
11 | });
12 |
13 | it('focusApp should be ok', async () => {
14 | await osaUtil.focusApp('Notes');
15 | const win = await new MacacaMacOS().getAppSizePosition('Notes');
16 | console.log(win);
17 | assert(win);
18 | });
19 |
20 | });
21 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2018",
4 | "module": "commonjs",
5 | "moduleResolution": "node",
6 | "experimentalDecorators": true,
7 | "emitDecoratorMetadata": true,
8 | "resolveJsonModule": true,
9 | "inlineSourceMap":true,
10 | "noImplicitThis": true,
11 | "esModuleInterop": true,
12 | "noUnusedLocals": true,
13 | "stripInternal": true,
14 | "pretty": true,
15 | "declaration": true,
16 | "removeComments": false,
17 | "types": [ "mocha", "node" ],
18 | "outDir": "./dist"
19 | },
20 | "include": [
21 | "src",
22 | "bin",
23 | "index.ts"
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------