keystroke-sender 0.1.0__tar.gz
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.
- keystroke_sender-0.1.0/.github/workflows/publish.yml +46 -0
- keystroke_sender-0.1.0/.gitignore +11 -0
- keystroke_sender-0.1.0/PKG-INFO +158 -0
- keystroke_sender-0.1.0/README.md +148 -0
- keystroke_sender-0.1.0/install.bat +53 -0
- keystroke_sender-0.1.0/install.sh +60 -0
- keystroke_sender-0.1.0/pyproject.toml +19 -0
- keystroke_sender-0.1.0/requirements.txt +1 -0
- keystroke_sender-0.1.0/src/keystroke_sender/__init__.py +1 -0
- keystroke_sender-0.1.0/src/keystroke_sender/host.py +230 -0
- keystroke_sender-0.1.0/src/keystroke_sender/register.py +142 -0
- keystroke_sender-0.1.0/uninstall.bat +30 -0
- keystroke_sender-0.1.0/uninstall.sh +24 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
id-token: write
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: "3.x"
|
|
19
|
+
|
|
20
|
+
- name: Install build dependencies
|
|
21
|
+
run: pip install build
|
|
22
|
+
|
|
23
|
+
- name: Build package
|
|
24
|
+
run: python -m build
|
|
25
|
+
|
|
26
|
+
- name: Upload build artifacts
|
|
27
|
+
uses: actions/upload-artifact@v4
|
|
28
|
+
with:
|
|
29
|
+
name: dist
|
|
30
|
+
path: dist/
|
|
31
|
+
|
|
32
|
+
publish:
|
|
33
|
+
needs: build
|
|
34
|
+
runs-on: ubuntu-latest
|
|
35
|
+
environment: pypi
|
|
36
|
+
permissions:
|
|
37
|
+
id-token: write
|
|
38
|
+
steps:
|
|
39
|
+
- name: Download build artifacts
|
|
40
|
+
uses: actions/download-artifact@v4
|
|
41
|
+
with:
|
|
42
|
+
name: dist
|
|
43
|
+
path: dist/
|
|
44
|
+
|
|
45
|
+
- name: Publish to PyPI
|
|
46
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keystroke-sender
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Chrome Native Messaging host that simulates OS-level keystrokes and mouse clicks
|
|
5
|
+
Project-URL: Repository, https://github.com/PropDream/keystroke-sender
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.7
|
|
8
|
+
Requires-Dist: pynput
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# Keystroke Sender
|
|
12
|
+
|
|
13
|
+
A Chrome Native Messaging host that simulates OS-level keystrokes. Receives text strings from a Chrome extension and types them out as real key presses using `pynput`.
|
|
14
|
+
|
|
15
|
+
Works on **macOS**, **Linux**, and **Windows**.
|
|
16
|
+
|
|
17
|
+
Companion extension: [Chrome Form Filler](https://chromewebstore.google.com/detail/chrome-form-filler/dpdolkkncejkelemckjmjoaefmgdhepj) (`dpdolkkncejkelemckjmjoaefmgdhepj`)
|
|
18
|
+
|
|
19
|
+
## Prerequisites
|
|
20
|
+
|
|
21
|
+
- Python 3.7+
|
|
22
|
+
- pip
|
|
23
|
+
- Google Chrome
|
|
24
|
+
- [Chrome Form Filler](https://chromewebstore.google.com/detail/chrome-form-filler/dpdolkkncejkelemckjmjoaefmgdhepj) extension
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
### pip install (recommended)
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install git+https://github.com/PropDream/keystroke-sender.git
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Then register the Chrome native messaging host:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
keystroke-sender-register YOUR_EXTENSION_ID
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
To unregister later:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
keystroke-sender-register --unregister
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Manual install (macOS / Linux)
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
chmod +x install.sh
|
|
50
|
+
./install.sh
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Manual install (Windows)
|
|
54
|
+
|
|
55
|
+
```cmd
|
|
56
|
+
install.bat
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The manual installer will:
|
|
60
|
+
1. Prompt you for your Chrome extension ID (find it at `chrome://extensions`)
|
|
61
|
+
2. Create the native messaging host manifest in the correct OS location
|
|
62
|
+
3. Install the `pynput` Python dependency
|
|
63
|
+
|
|
64
|
+
### macOS Accessibility Permission
|
|
65
|
+
|
|
66
|
+
On macOS, you must grant Accessibility permission to your terminal app (or Python) for keystroke simulation to work:
|
|
67
|
+
|
|
68
|
+
**System Preferences > Privacy & Security > Accessibility** — add your terminal app (Terminal, iTerm2, etc.).
|
|
69
|
+
|
|
70
|
+
## Usage
|
|
71
|
+
|
|
72
|
+
From a Chrome extension, send a message to the native host:
|
|
73
|
+
|
|
74
|
+
```javascript
|
|
75
|
+
chrome.runtime.sendNativeMessage(
|
|
76
|
+
"com.propdream.keystroke_sender",
|
|
77
|
+
{ text: "Hello, world!" },
|
|
78
|
+
(response) => {
|
|
79
|
+
console.log(response);
|
|
80
|
+
// { status: "ok", typed: 13 }
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Message Format
|
|
86
|
+
|
|
87
|
+
**Request:**
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"text": "string to type",
|
|
91
|
+
"delay": 0.05
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- `text` (required): The string to type as OS-level keystrokes.
|
|
96
|
+
- `delay` (optional): Seconds between each keystroke. Default: `0.05`.
|
|
97
|
+
|
|
98
|
+
**Response:**
|
|
99
|
+
```json
|
|
100
|
+
{ "status": "ok", "typed": 13 }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
or on error:
|
|
104
|
+
```json
|
|
105
|
+
{ "status": "error", "message": "description" }
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Extension Setup
|
|
109
|
+
|
|
110
|
+
Add `"nativeMessaging"` to your extension's `manifest.json` permissions:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"permissions": ["nativeMessaging"]
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Uninstallation
|
|
119
|
+
|
|
120
|
+
### macOS / Linux
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
chmod +x uninstall.sh
|
|
124
|
+
./uninstall.sh
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Windows
|
|
128
|
+
|
|
129
|
+
```cmd
|
|
130
|
+
uninstall.bat
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Security
|
|
134
|
+
|
|
135
|
+
This app simulates real keystrokes and mouse clicks at the OS level, so it includes several safeguards to prevent misuse:
|
|
136
|
+
|
|
137
|
+
**Chrome Native Messaging isolation** — The host process can *only* be launched by Chrome, and *only* the extension ID you registered in `allowed_origins` can send it messages. No network socket is opened; communication is purely via stdin/stdout. An unauthorized extension or external program cannot talk to it.
|
|
138
|
+
|
|
139
|
+
**Idle timeout** — The host automatically exits after **1 minute** of inactivity. It does not stay running indefinitely.
|
|
140
|
+
|
|
141
|
+
**Rate limiting** — A maximum of **100 actions per second** is enforced. Bursts beyond this are rejected with an error response.
|
|
142
|
+
|
|
143
|
+
**Input size limits** — Messages larger than **1 MB** are rejected before parsing. Text payloads longer than **10,000 characters** are rejected before typing.
|
|
144
|
+
|
|
145
|
+
| Limit | Default |
|
|
146
|
+
|---|---|
|
|
147
|
+
| Idle timeout | 60 s (1 min) |
|
|
148
|
+
| Rate limit | 100 actions / 1 s |
|
|
149
|
+
| Max message size | 1 MB |
|
|
150
|
+
| Max text length | 10,000 chars |
|
|
151
|
+
|
|
152
|
+
These constants are defined at the top of `src/keystroke_sender/host.py` and can be adjusted if needed.
|
|
153
|
+
|
|
154
|
+
## Debugging
|
|
155
|
+
|
|
156
|
+
- Launch Chrome from the terminal to see native host stderr output
|
|
157
|
+
- Use `chrome://extensions` > Service Worker > Console to see extension-side errors
|
|
158
|
+
- Check `chrome.runtime.lastError` in the `sendNativeMessage` callback
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Keystroke Sender
|
|
2
|
+
|
|
3
|
+
A Chrome Native Messaging host that simulates OS-level keystrokes. Receives text strings from a Chrome extension and types them out as real key presses using `pynput`.
|
|
4
|
+
|
|
5
|
+
Works on **macOS**, **Linux**, and **Windows**.
|
|
6
|
+
|
|
7
|
+
Companion extension: [Chrome Form Filler](https://chromewebstore.google.com/detail/chrome-form-filler/dpdolkkncejkelemckjmjoaefmgdhepj) (`dpdolkkncejkelemckjmjoaefmgdhepj`)
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- Python 3.7+
|
|
12
|
+
- pip
|
|
13
|
+
- Google Chrome
|
|
14
|
+
- [Chrome Form Filler](https://chromewebstore.google.com/detail/chrome-form-filler/dpdolkkncejkelemckjmjoaefmgdhepj) extension
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
### pip install (recommended)
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install git+https://github.com/PropDream/keystroke-sender.git
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Then register the Chrome native messaging host:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
keystroke-sender-register YOUR_EXTENSION_ID
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
To unregister later:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
keystroke-sender-register --unregister
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Manual install (macOS / Linux)
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
chmod +x install.sh
|
|
40
|
+
./install.sh
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Manual install (Windows)
|
|
44
|
+
|
|
45
|
+
```cmd
|
|
46
|
+
install.bat
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The manual installer will:
|
|
50
|
+
1. Prompt you for your Chrome extension ID (find it at `chrome://extensions`)
|
|
51
|
+
2. Create the native messaging host manifest in the correct OS location
|
|
52
|
+
3. Install the `pynput` Python dependency
|
|
53
|
+
|
|
54
|
+
### macOS Accessibility Permission
|
|
55
|
+
|
|
56
|
+
On macOS, you must grant Accessibility permission to your terminal app (or Python) for keystroke simulation to work:
|
|
57
|
+
|
|
58
|
+
**System Preferences > Privacy & Security > Accessibility** — add your terminal app (Terminal, iTerm2, etc.).
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
From a Chrome extension, send a message to the native host:
|
|
63
|
+
|
|
64
|
+
```javascript
|
|
65
|
+
chrome.runtime.sendNativeMessage(
|
|
66
|
+
"com.propdream.keystroke_sender",
|
|
67
|
+
{ text: "Hello, world!" },
|
|
68
|
+
(response) => {
|
|
69
|
+
console.log(response);
|
|
70
|
+
// { status: "ok", typed: 13 }
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Message Format
|
|
76
|
+
|
|
77
|
+
**Request:**
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"text": "string to type",
|
|
81
|
+
"delay": 0.05
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- `text` (required): The string to type as OS-level keystrokes.
|
|
86
|
+
- `delay` (optional): Seconds between each keystroke. Default: `0.05`.
|
|
87
|
+
|
|
88
|
+
**Response:**
|
|
89
|
+
```json
|
|
90
|
+
{ "status": "ok", "typed": 13 }
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
or on error:
|
|
94
|
+
```json
|
|
95
|
+
{ "status": "error", "message": "description" }
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Extension Setup
|
|
99
|
+
|
|
100
|
+
Add `"nativeMessaging"` to your extension's `manifest.json` permissions:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"permissions": ["nativeMessaging"]
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Uninstallation
|
|
109
|
+
|
|
110
|
+
### macOS / Linux
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
chmod +x uninstall.sh
|
|
114
|
+
./uninstall.sh
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Windows
|
|
118
|
+
|
|
119
|
+
```cmd
|
|
120
|
+
uninstall.bat
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Security
|
|
124
|
+
|
|
125
|
+
This app simulates real keystrokes and mouse clicks at the OS level, so it includes several safeguards to prevent misuse:
|
|
126
|
+
|
|
127
|
+
**Chrome Native Messaging isolation** — The host process can *only* be launched by Chrome, and *only* the extension ID you registered in `allowed_origins` can send it messages. No network socket is opened; communication is purely via stdin/stdout. An unauthorized extension or external program cannot talk to it.
|
|
128
|
+
|
|
129
|
+
**Idle timeout** — The host automatically exits after **1 minute** of inactivity. It does not stay running indefinitely.
|
|
130
|
+
|
|
131
|
+
**Rate limiting** — A maximum of **100 actions per second** is enforced. Bursts beyond this are rejected with an error response.
|
|
132
|
+
|
|
133
|
+
**Input size limits** — Messages larger than **1 MB** are rejected before parsing. Text payloads longer than **10,000 characters** are rejected before typing.
|
|
134
|
+
|
|
135
|
+
| Limit | Default |
|
|
136
|
+
|---|---|
|
|
137
|
+
| Idle timeout | 60 s (1 min) |
|
|
138
|
+
| Rate limit | 100 actions / 1 s |
|
|
139
|
+
| Max message size | 1 MB |
|
|
140
|
+
| Max text length | 10,000 chars |
|
|
141
|
+
|
|
142
|
+
These constants are defined at the top of `src/keystroke_sender/host.py` and can be adjusted if needed.
|
|
143
|
+
|
|
144
|
+
## Debugging
|
|
145
|
+
|
|
146
|
+
- Launch Chrome from the terminal to see native host stderr output
|
|
147
|
+
- Use `chrome://extensions` > Service Worker > Console to see extension-side errors
|
|
148
|
+
- Check `chrome.runtime.lastError` in the `sendNativeMessage` callback
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
REM install.bat — Install the Chrome Native Messaging host on Windows
|
|
3
|
+
setlocal enabledelayedexpansion
|
|
4
|
+
|
|
5
|
+
set HOST_NAME=com.propdream.keystroke_sender
|
|
6
|
+
set SCRIPT_DIR=%~dp0
|
|
7
|
+
set HOST_PATH=%SCRIPT_DIR%keystroke_sender_wrapper.bat
|
|
8
|
+
|
|
9
|
+
REM Create the wrapper batch file that Chrome will execute
|
|
10
|
+
echo @echo off > "%SCRIPT_DIR%keystroke_sender_wrapper.bat"
|
|
11
|
+
echo python "%SCRIPT_DIR%keystroke_sender.py" %%* >> "%SCRIPT_DIR%keystroke_sender_wrapper.bat"
|
|
12
|
+
|
|
13
|
+
REM Set manifest directory
|
|
14
|
+
set TARGET_DIR=%LOCALAPPDATA%\Google\Chrome\User Data\NativeMessagingHosts
|
|
15
|
+
|
|
16
|
+
REM Prompt for Chrome extension ID
|
|
17
|
+
set /p EXTENSION_ID="Enter your Chrome extension ID (found at chrome://extensions): "
|
|
18
|
+
|
|
19
|
+
if "%EXTENSION_ID%"=="" (
|
|
20
|
+
echo Error: Extension ID cannot be empty.
|
|
21
|
+
exit /b 1
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
REM Create target directory
|
|
25
|
+
if not exist "%TARGET_DIR%" mkdir "%TARGET_DIR%"
|
|
26
|
+
|
|
27
|
+
REM Write the native messaging host manifest
|
|
28
|
+
(
|
|
29
|
+
echo {
|
|
30
|
+
echo "name": "%HOST_NAME%",
|
|
31
|
+
echo "description": "Simulates OS-level keystrokes for Chrome Form Filler",
|
|
32
|
+
echo "path": "%HOST_PATH:\=\\%",
|
|
33
|
+
echo "type": "stdio",
|
|
34
|
+
echo "allowed_origins": [
|
|
35
|
+
echo "chrome-extension://%EXTENSION_ID%/"
|
|
36
|
+
echo ]
|
|
37
|
+
echo }
|
|
38
|
+
) > "%TARGET_DIR%\%HOST_NAME%.json"
|
|
39
|
+
|
|
40
|
+
REM Add registry key (required on Windows)
|
|
41
|
+
reg add "HKCU\Software\Google\Chrome\NativeMessagingHosts\%HOST_NAME%" /ve /t REG_SZ /d "%TARGET_DIR%\%HOST_NAME%.json" /f
|
|
42
|
+
|
|
43
|
+
REM Install Python dependencies
|
|
44
|
+
echo Installing Python dependencies...
|
|
45
|
+
pip install -r "%SCRIPT_DIR%requirements.txt"
|
|
46
|
+
|
|
47
|
+
echo.
|
|
48
|
+
echo Native messaging host '%HOST_NAME%' installed successfully.
|
|
49
|
+
echo Manifest: %TARGET_DIR%\%HOST_NAME%.json
|
|
50
|
+
echo Host: %HOST_PATH%
|
|
51
|
+
echo Registry: HKCU\Software\Google\Chrome\NativeMessagingHosts\%HOST_NAME%
|
|
52
|
+
|
|
53
|
+
endlocal
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# install.sh — Install the Chrome Native Messaging host on macOS or Linux
|
|
3
|
+
set -e
|
|
4
|
+
|
|
5
|
+
HOST_NAME="com.propdream.keystroke_sender"
|
|
6
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
7
|
+
HOST_PATH="$SCRIPT_DIR/keystroke_sender.py"
|
|
8
|
+
|
|
9
|
+
# Detect OS and set manifest directory
|
|
10
|
+
if [[ "$OSTYPE" == "darwin"* ]]; then
|
|
11
|
+
TARGET_DIR="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts"
|
|
12
|
+
elif [[ "$OSTYPE" == "linux"* ]]; then
|
|
13
|
+
TARGET_DIR="$HOME/.config/google-chrome/NativeMessagingHosts"
|
|
14
|
+
else
|
|
15
|
+
echo "Unsupported OS: $OSTYPE"
|
|
16
|
+
echo "Use install.bat for Windows."
|
|
17
|
+
exit 1
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
# Prompt for Chrome extension ID
|
|
21
|
+
echo "Enter your Chrome extension ID (found at chrome://extensions):"
|
|
22
|
+
read -r EXTENSION_ID
|
|
23
|
+
|
|
24
|
+
if [ -z "$EXTENSION_ID" ]; then
|
|
25
|
+
echo "Error: Extension ID cannot be empty."
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
# Create target directory
|
|
30
|
+
mkdir -p "$TARGET_DIR"
|
|
31
|
+
|
|
32
|
+
# Write the native messaging host manifest
|
|
33
|
+
cat > "$TARGET_DIR/$HOST_NAME.json" << EOF
|
|
34
|
+
{
|
|
35
|
+
"name": "$HOST_NAME",
|
|
36
|
+
"description": "Simulates OS-level keystrokes for Chrome Form Filler",
|
|
37
|
+
"path": "$HOST_PATH",
|
|
38
|
+
"type": "stdio",
|
|
39
|
+
"allowed_origins": [
|
|
40
|
+
"chrome-extension://$EXTENSION_ID/"
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
EOF
|
|
44
|
+
|
|
45
|
+
# Make the host script executable
|
|
46
|
+
chmod +x "$HOST_PATH"
|
|
47
|
+
|
|
48
|
+
# Install Python dependencies
|
|
49
|
+
echo "Installing Python dependencies..."
|
|
50
|
+
pip3 install -r "$SCRIPT_DIR/requirements.txt"
|
|
51
|
+
|
|
52
|
+
echo ""
|
|
53
|
+
echo "Native messaging host '$HOST_NAME' installed successfully."
|
|
54
|
+
echo " Manifest: $TARGET_DIR/$HOST_NAME.json"
|
|
55
|
+
echo " Host: $HOST_PATH"
|
|
56
|
+
echo ""
|
|
57
|
+
if [[ "$OSTYPE" == "darwin"* ]]; then
|
|
58
|
+
echo "IMPORTANT: On macOS, you must grant Accessibility permission to your"
|
|
59
|
+
echo "terminal/Python in System Preferences > Privacy & Security > Accessibility."
|
|
60
|
+
fi
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "keystroke-sender"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Chrome Native Messaging host that simulates OS-level keystrokes and mouse clicks"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.7"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
dependencies = ["pynput"]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
keystroke-sender = "keystroke_sender.host:main"
|
|
16
|
+
keystroke-sender-register = "keystroke_sender.register:main"
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Repository = "https://github.com/PropDream/keystroke-sender"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pynput
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Chrome Native Messaging host that simulates OS-level keystrokes and mouse clicks.
|
|
4
|
+
|
|
5
|
+
Receives JSON messages from a Chrome extension via stdin and performs
|
|
6
|
+
OS-level input simulation using pynput. Works on macOS, Linux, and Windows.
|
|
7
|
+
|
|
8
|
+
Message protocol: 4-byte uint32 length prefix (native byte order) + UTF-8 JSON.
|
|
9
|
+
|
|
10
|
+
Actions:
|
|
11
|
+
type: {"action": "type", "text": "string to type", "delay": 0.05}
|
|
12
|
+
-> {"status": "ok", "typed": <count>}
|
|
13
|
+
click: {"action": "click", "x": 500, "y": 300}
|
|
14
|
+
-> {"status": "ok", "action": "click", "x": 500, "y": 300}
|
|
15
|
+
|
|
16
|
+
Legacy (no action field): {"text": "..."} is treated as type action.
|
|
17
|
+
Error response: {"status": "error", "message": "..."}
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
import os
|
|
22
|
+
import json
|
|
23
|
+
import struct
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
|
|
27
|
+
# Ensure unbuffered stdout (critical for native messaging protocol)
|
|
28
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
29
|
+
sys.stdout.reconfigure(write_through=True)
|
|
30
|
+
os.environ["PYTHONUNBUFFERED"] = "1"
|
|
31
|
+
|
|
32
|
+
# --- Safety limits ---
|
|
33
|
+
MAX_MESSAGE_BYTES = 1_048_576 # 1 MB — reject anything larger
|
|
34
|
+
MAX_TEXT_LENGTH = 10_000 # max characters per type action
|
|
35
|
+
IDLE_TIMEOUT_SECONDS = 60 # exit after 1 min with no messages
|
|
36
|
+
RATE_LIMIT_WINDOW = 1.0 # rolling window in seconds
|
|
37
|
+
RATE_LIMIT_MAX = 100 # max actions per window
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def log(text):
|
|
41
|
+
"""Write debug output to stderr (never stdout)."""
|
|
42
|
+
sys.stderr.write(f"[keystroke-sender] {text}\n")
|
|
43
|
+
sys.stderr.flush()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_message():
|
|
47
|
+
"""Read a single native messaging message from stdin.
|
|
48
|
+
|
|
49
|
+
Raises ValueError if the message exceeds MAX_MESSAGE_BYTES.
|
|
50
|
+
"""
|
|
51
|
+
raw_length = sys.stdin.buffer.read(4)
|
|
52
|
+
if len(raw_length) == 0:
|
|
53
|
+
# Chrome disconnected
|
|
54
|
+
sys.exit(0)
|
|
55
|
+
if len(raw_length) < 4:
|
|
56
|
+
log(f"Expected 4 bytes for length, got {len(raw_length)}")
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
message_length = struct.unpack("@I", raw_length)[0]
|
|
59
|
+
if message_length > MAX_MESSAGE_BYTES:
|
|
60
|
+
log(f"Message too large: {message_length} bytes (limit {MAX_MESSAGE_BYTES})")
|
|
61
|
+
raise ValueError(f"Message exceeds {MAX_MESSAGE_BYTES} byte limit")
|
|
62
|
+
raw_message = sys.stdin.buffer.read(message_length)
|
|
63
|
+
if len(raw_message) < message_length:
|
|
64
|
+
log(f"Expected {message_length} bytes, got {len(raw_message)}")
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
return json.loads(raw_message.decode("utf-8"))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def send_message(message_obj):
|
|
70
|
+
"""Send a single native messaging message to stdout."""
|
|
71
|
+
encoded = json.dumps(message_obj, separators=(",", ":")).encode("utf-8")
|
|
72
|
+
sys.stdout.buffer.write(struct.pack("@I", len(encoded)))
|
|
73
|
+
sys.stdout.buffer.write(encoded)
|
|
74
|
+
sys.stdout.buffer.flush()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def type_text(text, delay=0.05):
|
|
78
|
+
"""
|
|
79
|
+
Simulate OS-level keystrokes for the given text using pynput.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
text: The string to type.
|
|
83
|
+
delay: Seconds to wait between each keystroke.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
The number of characters typed.
|
|
87
|
+
"""
|
|
88
|
+
from pynput.keyboard import Controller
|
|
89
|
+
|
|
90
|
+
keyboard = Controller()
|
|
91
|
+
count = 0
|
|
92
|
+
for char in text:
|
|
93
|
+
keyboard.type(char)
|
|
94
|
+
count += 1
|
|
95
|
+
if delay > 0:
|
|
96
|
+
time.sleep(delay)
|
|
97
|
+
return count
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def click_at(x, y):
|
|
101
|
+
"""
|
|
102
|
+
Simulate an OS-level left mouse click at the given screen coordinates.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
x: Screen X coordinate (pixels from left edge).
|
|
106
|
+
y: Screen Y coordinate (pixels from top edge).
|
|
107
|
+
"""
|
|
108
|
+
from pynput.mouse import Controller, Button
|
|
109
|
+
|
|
110
|
+
mouse = Controller()
|
|
111
|
+
mouse.position = (x, y)
|
|
112
|
+
time.sleep(0.05) # brief pause after moving to ensure position is set
|
|
113
|
+
mouse.click(Button.left, 1)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _check_rate_limit(timestamps):
|
|
117
|
+
"""Enforce a sliding-window rate limit. Returns True if the action is allowed."""
|
|
118
|
+
now = time.monotonic()
|
|
119
|
+
# Discard timestamps outside the window
|
|
120
|
+
while timestamps and timestamps[0] <= now - RATE_LIMIT_WINDOW:
|
|
121
|
+
timestamps.pop(0)
|
|
122
|
+
if len(timestamps) >= RATE_LIMIT_MAX:
|
|
123
|
+
return False
|
|
124
|
+
timestamps.append(now)
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _start_idle_watchdog(last_activity_ref):
|
|
129
|
+
"""Background thread that exits the process after IDLE_TIMEOUT_SECONDS of inactivity."""
|
|
130
|
+
def watchdog():
|
|
131
|
+
while True:
|
|
132
|
+
elapsed = time.monotonic() - last_activity_ref[0]
|
|
133
|
+
remaining = IDLE_TIMEOUT_SECONDS - elapsed
|
|
134
|
+
if remaining <= 0:
|
|
135
|
+
log(f"Idle timeout ({IDLE_TIMEOUT_SECONDS}s) — exiting")
|
|
136
|
+
os._exit(0) # hard exit from background thread
|
|
137
|
+
time.sleep(min(remaining, 10))
|
|
138
|
+
t = threading.Thread(target=watchdog, daemon=True)
|
|
139
|
+
t.start()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main():
|
|
143
|
+
log("Native messaging host started")
|
|
144
|
+
log(f"Platform: {sys.platform}")
|
|
145
|
+
log(f"Safety limits: idle_timeout={IDLE_TIMEOUT_SECONDS}s, "
|
|
146
|
+
f"rate_limit={RATE_LIMIT_MAX}/{RATE_LIMIT_WINDOW}s, "
|
|
147
|
+
f"max_text={MAX_TEXT_LENGTH}, max_msg={MAX_MESSAGE_BYTES}B")
|
|
148
|
+
|
|
149
|
+
action_timestamps = [] # for rate limiting
|
|
150
|
+
# Mutable ref so the watchdog thread can see updates
|
|
151
|
+
last_activity = [time.monotonic()]
|
|
152
|
+
_start_idle_watchdog(last_activity)
|
|
153
|
+
|
|
154
|
+
while True:
|
|
155
|
+
try:
|
|
156
|
+
message = read_message()
|
|
157
|
+
except ValueError as e:
|
|
158
|
+
# Message size exceeded
|
|
159
|
+
send_message({"status": "error", "message": str(e)})
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
last_activity[0] = time.monotonic()
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
log(f"Received: {message}")
|
|
166
|
+
|
|
167
|
+
# --- Rate limit ---
|
|
168
|
+
if not _check_rate_limit(action_timestamps):
|
|
169
|
+
log("Rate limit exceeded")
|
|
170
|
+
send_message({"status": "error", "message": f"Rate limit exceeded ({RATE_LIMIT_MAX} actions per {RATE_LIMIT_WINDOW}s)"})
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
# Determine action: explicit "action" field, or default to "type" if "text" present
|
|
174
|
+
action = message.get("action")
|
|
175
|
+
if action is None:
|
|
176
|
+
if "text" in message:
|
|
177
|
+
action = "type"
|
|
178
|
+
else:
|
|
179
|
+
send_message({"status": "error", "message": "Missing 'action' field"})
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
if action == "type":
|
|
183
|
+
text = message.get("text")
|
|
184
|
+
if text is None:
|
|
185
|
+
send_message({"status": "error", "message": "Missing 'text' field"})
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
if not isinstance(text, str):
|
|
189
|
+
send_message({"status": "error", "message": "'text' must be a string"})
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
if len(text) > MAX_TEXT_LENGTH:
|
|
193
|
+
send_message({"status": "error", "message": f"Text too long ({len(text)} chars, limit {MAX_TEXT_LENGTH})"})
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
if len(text) == 0:
|
|
197
|
+
send_message({"status": "ok", "typed": 0})
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
delay = message.get("delay", 0.05)
|
|
201
|
+
typed = type_text(text, delay=delay)
|
|
202
|
+
log(f"Typed {typed} characters")
|
|
203
|
+
send_message({"status": "ok", "typed": typed})
|
|
204
|
+
|
|
205
|
+
elif action == "click":
|
|
206
|
+
x = message.get("x")
|
|
207
|
+
y = message.get("y")
|
|
208
|
+
if x is None or y is None:
|
|
209
|
+
send_message({"status": "error", "message": "Missing 'x' or 'y' for click"})
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
x = int(x)
|
|
213
|
+
y = int(y)
|
|
214
|
+
click_at(x, y)
|
|
215
|
+
log(f"Clicked at ({x}, {y})")
|
|
216
|
+
send_message({"status": "ok", "action": "click", "x": x, "y": y})
|
|
217
|
+
|
|
218
|
+
else:
|
|
219
|
+
send_message({"status": "error", "message": f"Unknown action: {action}"})
|
|
220
|
+
|
|
221
|
+
except json.JSONDecodeError as e:
|
|
222
|
+
log(f"Invalid JSON: {e}")
|
|
223
|
+
send_message({"status": "error", "message": f"Invalid JSON: {e}"})
|
|
224
|
+
except Exception as e:
|
|
225
|
+
log(f"Error: {e}")
|
|
226
|
+
send_message({"status": "error", "message": str(e)})
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
if __name__ == "__main__":
|
|
230
|
+
main()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Register (or unregister) the Chrome Native Messaging host manifest.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
keystroke-sender-register <extension-id>
|
|
7
|
+
keystroke-sender-register --unregister
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
HOST_NAME = "com.propdream.keystroke_sender"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _manifest_dir():
|
|
21
|
+
"""Return the platform-specific directory for Chrome native messaging manifests."""
|
|
22
|
+
if sys.platform == "darwin":
|
|
23
|
+
return os.path.expanduser(
|
|
24
|
+
"~/Library/Application Support/Google/Chrome/NativeMessagingHosts"
|
|
25
|
+
)
|
|
26
|
+
elif sys.platform == "linux":
|
|
27
|
+
return os.path.expanduser(
|
|
28
|
+
"~/.config/google-chrome/NativeMessagingHosts"
|
|
29
|
+
)
|
|
30
|
+
elif sys.platform == "win32":
|
|
31
|
+
return os.path.join(
|
|
32
|
+
os.environ.get("LOCALAPPDATA", ""),
|
|
33
|
+
"Google", "Chrome", "User Data", "NativeMessagingHosts",
|
|
34
|
+
)
|
|
35
|
+
else:
|
|
36
|
+
sys.exit(f"Unsupported platform: {sys.platform}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _host_executable():
|
|
40
|
+
"""Find the installed keystroke-sender console script."""
|
|
41
|
+
path = shutil.which("keystroke-sender")
|
|
42
|
+
if path is None:
|
|
43
|
+
sys.exit(
|
|
44
|
+
"Error: 'keystroke-sender' not found on PATH.\n"
|
|
45
|
+
"Install the package first: pip install keystroke-sender"
|
|
46
|
+
)
|
|
47
|
+
return os.path.abspath(path)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def register(extension_id):
|
|
51
|
+
"""Write the native messaging host manifest and (on Windows) the registry key."""
|
|
52
|
+
manifest_dir = _manifest_dir()
|
|
53
|
+
os.makedirs(manifest_dir, exist_ok=True)
|
|
54
|
+
|
|
55
|
+
host_path = _host_executable()
|
|
56
|
+
|
|
57
|
+
# On Windows, Chrome expects a .bat or .exe — the pip console_script is an .exe
|
|
58
|
+
manifest = {
|
|
59
|
+
"name": HOST_NAME,
|
|
60
|
+
"description": "Simulates OS-level keystrokes for Chrome Form Filler",
|
|
61
|
+
"path": host_path,
|
|
62
|
+
"type": "stdio",
|
|
63
|
+
"allowed_origins": [f"chrome-extension://{extension_id}/"],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
manifest_path = os.path.join(manifest_dir, f"{HOST_NAME}.json")
|
|
67
|
+
with open(manifest_path, "w") as f:
|
|
68
|
+
json.dump(manifest, f, indent=2)
|
|
69
|
+
|
|
70
|
+
print(f"Manifest written to: {manifest_path}")
|
|
71
|
+
print(f"Host executable: {host_path}")
|
|
72
|
+
|
|
73
|
+
# Windows requires a registry key pointing to the manifest
|
|
74
|
+
if sys.platform == "win32":
|
|
75
|
+
reg_key = rf"HKCU\Software\Google\Chrome\NativeMessagingHosts\{HOST_NAME}"
|
|
76
|
+
subprocess.run(
|
|
77
|
+
["reg", "add", reg_key, "/ve", "/t", "REG_SZ", "/d", manifest_path, "/f"],
|
|
78
|
+
check=True,
|
|
79
|
+
)
|
|
80
|
+
print(f"Registry key: {reg_key}")
|
|
81
|
+
|
|
82
|
+
print()
|
|
83
|
+
print(f"Native messaging host '{HOST_NAME}' registered successfully.")
|
|
84
|
+
|
|
85
|
+
if sys.platform == "darwin":
|
|
86
|
+
print()
|
|
87
|
+
print("IMPORTANT: On macOS, grant Accessibility permission to your")
|
|
88
|
+
print("terminal/Python in System Preferences > Privacy & Security > Accessibility.")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def unregister():
|
|
92
|
+
"""Remove the native messaging host manifest and (on Windows) the registry key."""
|
|
93
|
+
manifest_dir = _manifest_dir()
|
|
94
|
+
manifest_path = os.path.join(manifest_dir, f"{HOST_NAME}.json")
|
|
95
|
+
|
|
96
|
+
if os.path.exists(manifest_path):
|
|
97
|
+
os.remove(manifest_path)
|
|
98
|
+
print(f"Removed manifest: {manifest_path}")
|
|
99
|
+
else:
|
|
100
|
+
print(f"Manifest not found: {manifest_path}")
|
|
101
|
+
|
|
102
|
+
if sys.platform == "win32":
|
|
103
|
+
reg_key = rf"HKCU\Software\Google\Chrome\NativeMessagingHosts\{HOST_NAME}"
|
|
104
|
+
subprocess.run(
|
|
105
|
+
["reg", "delete", reg_key, "/f"],
|
|
106
|
+
check=False, # OK if key doesn't exist
|
|
107
|
+
)
|
|
108
|
+
print(f"Removed registry key: {reg_key}")
|
|
109
|
+
|
|
110
|
+
print(f"Native messaging host '{HOST_NAME}' unregistered.")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def main():
|
|
114
|
+
parser = argparse.ArgumentParser(
|
|
115
|
+
description="Register the keystroke-sender Chrome Native Messaging host."
|
|
116
|
+
)
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
"extension_id",
|
|
119
|
+
nargs="?",
|
|
120
|
+
help="Chrome extension ID (find it at chrome://extensions)",
|
|
121
|
+
)
|
|
122
|
+
parser.add_argument(
|
|
123
|
+
"--unregister",
|
|
124
|
+
action="store_true",
|
|
125
|
+
help="Remove the native messaging host registration",
|
|
126
|
+
)
|
|
127
|
+
args = parser.parse_args()
|
|
128
|
+
|
|
129
|
+
if args.unregister:
|
|
130
|
+
unregister()
|
|
131
|
+
elif args.extension_id:
|
|
132
|
+
register(args.extension_id)
|
|
133
|
+
else:
|
|
134
|
+
# Interactive prompt
|
|
135
|
+
ext_id = input("Enter your Chrome extension ID (found at chrome://extensions): ").strip()
|
|
136
|
+
if not ext_id:
|
|
137
|
+
sys.exit("Error: Extension ID cannot be empty.")
|
|
138
|
+
register(ext_id)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
main()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
REM uninstall.bat — Remove the Chrome Native Messaging host on Windows
|
|
3
|
+
setlocal
|
|
4
|
+
|
|
5
|
+
set HOST_NAME=com.propdream.keystroke_sender
|
|
6
|
+
set TARGET_DIR=%LOCALAPPDATA%\Google\Chrome\User Data\NativeMessagingHosts
|
|
7
|
+
set SCRIPT_DIR=%~dp0
|
|
8
|
+
|
|
9
|
+
REM Remove manifest file
|
|
10
|
+
if exist "%TARGET_DIR%\%HOST_NAME%.json" (
|
|
11
|
+
del "%TARGET_DIR%\%HOST_NAME%.json"
|
|
12
|
+
echo Removed manifest: %TARGET_DIR%\%HOST_NAME%.json
|
|
13
|
+
) else (
|
|
14
|
+
echo Manifest not found — nothing to remove.
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
REM Remove registry key
|
|
18
|
+
reg delete "HKCU\Software\Google\Chrome\NativeMessagingHosts\%HOST_NAME%" /f 2>nul
|
|
19
|
+
echo Removed registry key.
|
|
20
|
+
|
|
21
|
+
REM Remove wrapper batch file
|
|
22
|
+
if exist "%SCRIPT_DIR%keystroke_sender_wrapper.bat" (
|
|
23
|
+
del "%SCRIPT_DIR%keystroke_sender_wrapper.bat"
|
|
24
|
+
echo Removed wrapper script.
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
echo.
|
|
28
|
+
echo Native messaging host '%HOST_NAME%' uninstalled.
|
|
29
|
+
|
|
30
|
+
endlocal
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# uninstall.sh — Remove the Chrome Native Messaging host on macOS or Linux
|
|
3
|
+
set -e
|
|
4
|
+
|
|
5
|
+
HOST_NAME="com.propdream.keystroke_sender"
|
|
6
|
+
|
|
7
|
+
if [[ "$OSTYPE" == "darwin"* ]]; then
|
|
8
|
+
TARGET_DIR="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts"
|
|
9
|
+
elif [[ "$OSTYPE" == "linux"* ]]; then
|
|
10
|
+
TARGET_DIR="$HOME/.config/google-chrome/NativeMessagingHosts"
|
|
11
|
+
else
|
|
12
|
+
echo "Unsupported OS: $OSTYPE"
|
|
13
|
+
exit 1
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
MANIFEST="$TARGET_DIR/$HOST_NAME.json"
|
|
17
|
+
|
|
18
|
+
if [ -f "$MANIFEST" ]; then
|
|
19
|
+
rm -f "$MANIFEST"
|
|
20
|
+
echo "Native messaging host '$HOST_NAME' uninstalled."
|
|
21
|
+
echo " Removed: $MANIFEST"
|
|
22
|
+
else
|
|
23
|
+
echo "Manifest not found at $MANIFEST — nothing to remove."
|
|
24
|
+
fi
|