block-proxy 0.1.12 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +26 -1
- package/.claude/skills/build-client/skill.md +24 -0
- package/.claude/skills/release-client/skill.md +68 -0
- package/CLAUDE.md +69 -67
- package/Dockerfile +1 -1
- package/README.md +38 -24
- package/build/asset-manifest.json +6 -6
- package/build/index.html +1 -1
- package/build/static/css/main.3f317ce6.css +2 -0
- package/build/static/css/main.3f317ce6.css.map +1 -0
- package/build/static/js/{main.2247fb80.js → main.68f66be0.js} +3 -3
- package/build/static/js/main.68f66be0.js.map +1 -0
- package/client/app.py +312 -0
- package/client/build.sh +84 -0
- package/client/config.py +49 -0
- package/client/config_window.py +155 -0
- package/client/icons/app.icns +0 -0
- package/client/icons/app_example.png +0 -0
- package/client/icons/app_icon.png +0 -0
- package/client/icons/backup/app_example.png +0 -0
- package/client/icons/backup/christmas-sock_dark.png +0 -0
- package/client/icons/backup/christmas-sock_light.png +0 -0
- package/client/icons/backup/socks_on_G.png +0 -0
- package/client/icons/backup/socks_on_M.png +0 -0
- package/client/icons/christmas-sock_dark.png +0 -0
- package/client/icons/christmas-sock_light.png +0 -0
- package/client/icons/christmas-sock_light_bar.png +0 -0
- package/client/icons/socks_on_G.png +0 -0
- package/client/icons/socks_on_G_bar.png +0 -0
- package/client/icons/socks_on_M.png +0 -0
- package/client/icons/socks_on_M_bar.png +0 -0
- package/client/main.py +28 -0
- package/client/proxy_core.py +475 -0
- package/client/requirements.txt +3 -0
- package/client/scripts/download_xray.sh +30 -0
- package/client/setup.py +30 -0
- package/client/system_proxy.py +94 -0
- package/client/tests/__init__.py +0 -0
- package/client/tests/test_config.py +72 -0
- package/client/tests/test_system_proxy.py +69 -0
- package/client/watch-icons.js +31 -0
- package/config.json +28 -3
- package/docs/superpowers/plans/2026-05-27-blockproxyclient.md +1274 -0
- package/docs/superpowers/specs/2026-05-27-blockproxyclient-design.md +264 -0
- package/package.json +10 -4
- package/proxy/proxy.js +19 -15
- package/server/express.js +17 -1
- package/src/App.css +596 -276
- package/src/App.js +25 -22
- package/src/index.css +3 -4
- package/test/lib/mock-server.js +133 -0
- package/test/proxy-tests.js +708 -0
- package/test/run.js +330 -0
- package/build/static/css/main.8bfa3d5f.css +0 -2
- package/build/static/css/main.8bfa3d5f.css.map +0 -1
- package/build/static/js/main.2247fb80.js.map +0 -1
- package/hack-of-anyproxy/lib/requestHandler.js +0 -1060
- /package/build/static/js/{main.2247fb80.js.LICENSE.txt → main.68f66be0.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,1274 @@
|
|
|
1
|
+
# BlockProxyClient Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Build a macOS status bar application that connects to a block-proxy SOCKS5 over TLS server via xray-core, providing local SOCKS5 and HTTP proxy ports with optional system-wide proxy configuration.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Python rumps app manages UI (status bar menu + tkinter config window). It generates xray-core JSON config from user settings and manages the xray-core subprocess. system_proxy module wraps macOS `networksetup` commands to toggle global proxy on/off.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Python 3.11+, rumps (macOS status bar), tkinter (config window), xray-core (binary, subprocess), py2app (packaging)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## File Structure
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
client/
|
|
17
|
+
├── main.py # Entry point, launches rumps app
|
|
18
|
+
├── app.py # BlockProxyClient rumps.App subclass, menu logic
|
|
19
|
+
├── config.py # Config read/write to ~/Library/Application Support/BlockProxyClient/config.json
|
|
20
|
+
├── xray_manager.py # Generate xray config JSON, start/stop subprocess, health monitoring
|
|
21
|
+
├── system_proxy.py # Detect active interfaces, set/clear macOS system proxy via networksetup
|
|
22
|
+
├── resources/
|
|
23
|
+
│ ├── icon.png # Status bar icon (connected) - 22x22 template image
|
|
24
|
+
│ ├── icon_off.png # Status bar icon (disconnected) - 22x22 template image
|
|
25
|
+
│ └── xray-core # xray-core binary (downloaded separately)
|
|
26
|
+
├── tests/
|
|
27
|
+
│ ├── test_config.py
|
|
28
|
+
│ ├── test_xray_manager.py
|
|
29
|
+
│ └── test_system_proxy.py
|
|
30
|
+
├── requirements.txt
|
|
31
|
+
└── setup.py # py2app packaging
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
### Task 1: Project Scaffolding
|
|
37
|
+
|
|
38
|
+
**Files:**
|
|
39
|
+
- Create: `client/requirements.txt`
|
|
40
|
+
- Create: `client/resources/.gitkeep`
|
|
41
|
+
- Create: `client/tests/__init__.py`
|
|
42
|
+
|
|
43
|
+
- [ ] **Step 1: Create directory structure**
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
cd /Users/bachi/jaylli/block-proxy
|
|
47
|
+
mkdir -p client/resources client/tests
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- [ ] **Step 2: Create requirements.txt**
|
|
51
|
+
|
|
52
|
+
Create `client/requirements.txt`:
|
|
53
|
+
```
|
|
54
|
+
rumps>=0.4.0
|
|
55
|
+
py2app>=0.28
|
|
56
|
+
pytest>=7.0
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- [ ] **Step 3: Create virtual environment and install dependencies**
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
cd /Users/bachi/jaylli/block-proxy/client
|
|
63
|
+
python3 -m venv .venv
|
|
64
|
+
source .venv/bin/activate
|
|
65
|
+
pip install -r requirements.txt
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- [ ] **Step 4: Create placeholder files**
|
|
69
|
+
|
|
70
|
+
Create `client/resources/.gitkeep` (empty file).
|
|
71
|
+
Create `client/tests/__init__.py` (empty file).
|
|
72
|
+
|
|
73
|
+
- [ ] **Step 5: Commit**
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
git add client/
|
|
77
|
+
git commit -m "feat(client): scaffold BlockProxyClient project structure"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
### Task 2: Configuration Module
|
|
83
|
+
|
|
84
|
+
**Files:**
|
|
85
|
+
- Create: `client/tests/test_config.py`
|
|
86
|
+
- Create: `client/config.py`
|
|
87
|
+
|
|
88
|
+
- [ ] **Step 1: Write failing tests for config module**
|
|
89
|
+
|
|
90
|
+
Create `client/tests/test_config.py`:
|
|
91
|
+
```python
|
|
92
|
+
import json
|
|
93
|
+
import os
|
|
94
|
+
import tempfile
|
|
95
|
+
import pytest
|
|
96
|
+
from config import Config, DEFAULT_CONFIG
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TestConfig:
|
|
100
|
+
def setup_method(self):
|
|
101
|
+
self.tmp_dir = tempfile.mkdtemp()
|
|
102
|
+
self.config_path = os.path.join(self.tmp_dir, "config.json")
|
|
103
|
+
self.config = Config(config_path=self.config_path)
|
|
104
|
+
|
|
105
|
+
def test_default_config_created_on_first_load(self):
|
|
106
|
+
data = self.config.load()
|
|
107
|
+
assert data["server"]["port"] == 8002
|
|
108
|
+
assert data["server"]["tls"] is True
|
|
109
|
+
assert data["server"]["allowInsecure"] is True
|
|
110
|
+
assert data["local"]["socks_port"] == 1080
|
|
111
|
+
assert data["local"]["http_port"] == 1087
|
|
112
|
+
assert data["local"]["udp"] is True
|
|
113
|
+
assert data["mode"] == "global"
|
|
114
|
+
assert os.path.exists(self.config_path)
|
|
115
|
+
|
|
116
|
+
def test_save_and_load_roundtrip(self):
|
|
117
|
+
self.config.load()
|
|
118
|
+
self.config.data["server"]["address"] = "10.0.0.1"
|
|
119
|
+
self.config.data["server"]["port"] = 9002
|
|
120
|
+
self.config.data["server"]["username"] = "user1"
|
|
121
|
+
self.config.data["server"]["password"] = "pass1"
|
|
122
|
+
self.config.save()
|
|
123
|
+
|
|
124
|
+
config2 = Config(config_path=self.config_path)
|
|
125
|
+
data = config2.load()
|
|
126
|
+
assert data["server"]["address"] == "10.0.0.1"
|
|
127
|
+
assert data["server"]["port"] == 9002
|
|
128
|
+
assert data["server"]["username"] == "user1"
|
|
129
|
+
assert data["server"]["password"] == "pass1"
|
|
130
|
+
|
|
131
|
+
def test_load_existing_config(self):
|
|
132
|
+
existing = {
|
|
133
|
+
"server": {
|
|
134
|
+
"address": "example.com",
|
|
135
|
+
"port": 443,
|
|
136
|
+
"username": "abc",
|
|
137
|
+
"password": "def",
|
|
138
|
+
"tls": False,
|
|
139
|
+
"allowInsecure": False,
|
|
140
|
+
},
|
|
141
|
+
"local": {"socks_port": 2080, "http_port": 2087, "udp": False},
|
|
142
|
+
"mode": "manual",
|
|
143
|
+
}
|
|
144
|
+
with open(self.config_path, "w") as f:
|
|
145
|
+
json.dump(existing, f)
|
|
146
|
+
|
|
147
|
+
data = self.config.load()
|
|
148
|
+
assert data["server"]["address"] == "example.com"
|
|
149
|
+
assert data["server"]["tls"] is False
|
|
150
|
+
assert data["local"]["socks_port"] == 2080
|
|
151
|
+
assert data["mode"] == "manual"
|
|
152
|
+
|
|
153
|
+
def test_is_configured_false_when_no_address(self):
|
|
154
|
+
self.config.load()
|
|
155
|
+
assert self.config.is_configured() is False
|
|
156
|
+
|
|
157
|
+
def test_is_configured_true_when_address_set(self):
|
|
158
|
+
self.config.load()
|
|
159
|
+
self.config.data["server"]["address"] = "10.0.0.1"
|
|
160
|
+
assert self.config.is_configured() is True
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
cd /Users/bachi/jaylli/block-proxy/client
|
|
167
|
+
source .venv/bin/activate
|
|
168
|
+
python -m pytest tests/test_config.py -v
|
|
169
|
+
```
|
|
170
|
+
Expected: FAIL with `ModuleNotFoundError: No module named 'config'`
|
|
171
|
+
|
|
172
|
+
- [ ] **Step 3: Implement config.py**
|
|
173
|
+
|
|
174
|
+
Create `client/config.py`:
|
|
175
|
+
```python
|
|
176
|
+
import json
|
|
177
|
+
import os
|
|
178
|
+
import copy
|
|
179
|
+
|
|
180
|
+
DEFAULT_CONFIG = {
|
|
181
|
+
"server": {
|
|
182
|
+
"address": "",
|
|
183
|
+
"port": 8002,
|
|
184
|
+
"username": "",
|
|
185
|
+
"password": "",
|
|
186
|
+
"tls": True,
|
|
187
|
+
"allowInsecure": True,
|
|
188
|
+
},
|
|
189
|
+
"local": {
|
|
190
|
+
"socks_port": 1080,
|
|
191
|
+
"http_port": 1087,
|
|
192
|
+
"udp": True,
|
|
193
|
+
},
|
|
194
|
+
"mode": "global",
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
DEFAULT_CONFIG_DIR = os.path.expanduser(
|
|
198
|
+
"~/Library/Application Support/BlockProxyClient"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class Config:
|
|
203
|
+
def __init__(self, config_path=None):
|
|
204
|
+
if config_path is None:
|
|
205
|
+
config_path = os.path.join(DEFAULT_CONFIG_DIR, "config.json")
|
|
206
|
+
self.config_path = config_path
|
|
207
|
+
self.data = None
|
|
208
|
+
|
|
209
|
+
def load(self):
|
|
210
|
+
if os.path.exists(self.config_path):
|
|
211
|
+
with open(self.config_path, "r") as f:
|
|
212
|
+
self.data = json.load(f)
|
|
213
|
+
else:
|
|
214
|
+
self.data = copy.deepcopy(DEFAULT_CONFIG)
|
|
215
|
+
self.save()
|
|
216
|
+
return self.data
|
|
217
|
+
|
|
218
|
+
def save(self):
|
|
219
|
+
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
|
|
220
|
+
with open(self.config_path, "w") as f:
|
|
221
|
+
json.dump(self.data, f, indent=2)
|
|
222
|
+
|
|
223
|
+
def is_configured(self):
|
|
224
|
+
return bool(self.data and self.data["server"]["address"])
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
cd /Users/bachi/jaylli/block-proxy/client
|
|
231
|
+
source .venv/bin/activate
|
|
232
|
+
python -m pytest tests/test_config.py -v
|
|
233
|
+
```
|
|
234
|
+
Expected: All 5 tests PASS
|
|
235
|
+
|
|
236
|
+
- [ ] **Step 5: Commit**
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
git add client/config.py client/tests/test_config.py
|
|
240
|
+
git commit -m "feat(client): add configuration management module"
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
### Task 3: xray-core Manager Module
|
|
246
|
+
|
|
247
|
+
**Files:**
|
|
248
|
+
- Create: `client/tests/test_xray_manager.py`
|
|
249
|
+
- Create: `client/xray_manager.py`
|
|
250
|
+
|
|
251
|
+
- [ ] **Step 1: Write failing tests for xray_manager**
|
|
252
|
+
|
|
253
|
+
Create `client/tests/test_xray_manager.py`:
|
|
254
|
+
```python
|
|
255
|
+
import json
|
|
256
|
+
import os
|
|
257
|
+
import tempfile
|
|
258
|
+
import pytest
|
|
259
|
+
from unittest.mock import patch, MagicMock
|
|
260
|
+
from xray_manager import XrayManager
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class TestXrayConfigGeneration:
|
|
264
|
+
def setup_method(self):
|
|
265
|
+
self.tmp_dir = tempfile.mkdtemp()
|
|
266
|
+
self.manager = XrayManager(
|
|
267
|
+
xray_path="/usr/local/bin/xray",
|
|
268
|
+
config_dir=self.tmp_dir,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def test_generate_config_basic(self):
|
|
272
|
+
user_config = {
|
|
273
|
+
"server": {
|
|
274
|
+
"address": "10.0.0.1",
|
|
275
|
+
"port": 8002,
|
|
276
|
+
"username": "user1",
|
|
277
|
+
"password": "pass1",
|
|
278
|
+
"tls": True,
|
|
279
|
+
"allowInsecure": True,
|
|
280
|
+
},
|
|
281
|
+
"local": {
|
|
282
|
+
"socks_port": 1080,
|
|
283
|
+
"http_port": 1087,
|
|
284
|
+
"udp": True,
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
config_path = self.manager.generate_config(user_config)
|
|
288
|
+
|
|
289
|
+
assert os.path.exists(config_path)
|
|
290
|
+
with open(config_path) as f:
|
|
291
|
+
xray_config = json.load(f)
|
|
292
|
+
|
|
293
|
+
assert len(xray_config["inbounds"]) == 2
|
|
294
|
+
|
|
295
|
+
socks_in = xray_config["inbounds"][0]
|
|
296
|
+
assert socks_in["port"] == 1080
|
|
297
|
+
assert socks_in["listen"] == "127.0.0.1"
|
|
298
|
+
assert socks_in["protocol"] == "socks"
|
|
299
|
+
assert socks_in["settings"]["udp"] is True
|
|
300
|
+
|
|
301
|
+
http_in = xray_config["inbounds"][1]
|
|
302
|
+
assert http_in["port"] == 1087
|
|
303
|
+
assert http_in["listen"] == "127.0.0.1"
|
|
304
|
+
assert http_in["protocol"] == "http"
|
|
305
|
+
|
|
306
|
+
outbound = xray_config["outbounds"][0]
|
|
307
|
+
assert outbound["protocol"] == "socks"
|
|
308
|
+
server = outbound["settings"]["servers"][0]
|
|
309
|
+
assert server["address"] == "10.0.0.1"
|
|
310
|
+
assert server["port"] == 8002
|
|
311
|
+
assert server["users"][0]["user"] == "user1"
|
|
312
|
+
assert server["users"][0]["pass"] == "pass1"
|
|
313
|
+
|
|
314
|
+
stream = outbound["streamSettings"]
|
|
315
|
+
assert stream["security"] == "tls"
|
|
316
|
+
assert stream["tlsSettings"]["allowInsecure"] is True
|
|
317
|
+
assert stream["tlsSettings"]["serverName"] == "10.0.0.1"
|
|
318
|
+
|
|
319
|
+
def test_generate_config_no_tls(self):
|
|
320
|
+
user_config = {
|
|
321
|
+
"server": {
|
|
322
|
+
"address": "10.0.0.1",
|
|
323
|
+
"port": 8002,
|
|
324
|
+
"username": "user1",
|
|
325
|
+
"password": "pass1",
|
|
326
|
+
"tls": False,
|
|
327
|
+
"allowInsecure": True,
|
|
328
|
+
},
|
|
329
|
+
"local": {
|
|
330
|
+
"socks_port": 1080,
|
|
331
|
+
"http_port": 1087,
|
|
332
|
+
"udp": False,
|
|
333
|
+
},
|
|
334
|
+
}
|
|
335
|
+
config_path = self.manager.generate_config(user_config)
|
|
336
|
+
|
|
337
|
+
with open(config_path) as f:
|
|
338
|
+
xray_config = json.load(f)
|
|
339
|
+
|
|
340
|
+
socks_in = xray_config["inbounds"][0]
|
|
341
|
+
assert socks_in["settings"]["udp"] is False
|
|
342
|
+
|
|
343
|
+
outbound = xray_config["outbounds"][0]
|
|
344
|
+
assert "streamSettings" not in outbound
|
|
345
|
+
|
|
346
|
+
def test_generate_config_no_auth(self):
|
|
347
|
+
user_config = {
|
|
348
|
+
"server": {
|
|
349
|
+
"address": "10.0.0.1",
|
|
350
|
+
"port": 8002,
|
|
351
|
+
"username": "",
|
|
352
|
+
"password": "",
|
|
353
|
+
"tls": True,
|
|
354
|
+
"allowInsecure": True,
|
|
355
|
+
},
|
|
356
|
+
"local": {
|
|
357
|
+
"socks_port": 1080,
|
|
358
|
+
"http_port": 1087,
|
|
359
|
+
"udp": True,
|
|
360
|
+
},
|
|
361
|
+
}
|
|
362
|
+
config_path = self.manager.generate_config(user_config)
|
|
363
|
+
|
|
364
|
+
with open(config_path) as f:
|
|
365
|
+
xray_config = json.load(f)
|
|
366
|
+
|
|
367
|
+
outbound = xray_config["outbounds"][0]
|
|
368
|
+
server = outbound["settings"]["servers"][0]
|
|
369
|
+
assert "users" not in server
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class TestXrayProcessManagement:
|
|
373
|
+
def setup_method(self):
|
|
374
|
+
self.tmp_dir = tempfile.mkdtemp()
|
|
375
|
+
self.manager = XrayManager(
|
|
376
|
+
xray_path="/usr/local/bin/xray",
|
|
377
|
+
config_dir=self.tmp_dir,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
@patch("subprocess.Popen")
|
|
381
|
+
def test_start_launches_process(self, mock_popen):
|
|
382
|
+
mock_process = MagicMock()
|
|
383
|
+
mock_process.poll.return_value = None
|
|
384
|
+
mock_popen.return_value = mock_process
|
|
385
|
+
|
|
386
|
+
user_config = {
|
|
387
|
+
"server": {
|
|
388
|
+
"address": "10.0.0.1",
|
|
389
|
+
"port": 8002,
|
|
390
|
+
"username": "u",
|
|
391
|
+
"password": "p",
|
|
392
|
+
"tls": True,
|
|
393
|
+
"allowInsecure": True,
|
|
394
|
+
},
|
|
395
|
+
"local": {"socks_port": 1080, "http_port": 1087, "udp": True},
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
self.manager.start(user_config)
|
|
399
|
+
|
|
400
|
+
mock_popen.assert_called_once()
|
|
401
|
+
args = mock_popen.call_args[0][0]
|
|
402
|
+
assert args[0] == "/usr/local/bin/xray"
|
|
403
|
+
assert args[1] == "run"
|
|
404
|
+
assert args[2] == "-c"
|
|
405
|
+
assert self.manager.is_running()
|
|
406
|
+
|
|
407
|
+
@patch("subprocess.Popen")
|
|
408
|
+
def test_stop_terminates_process(self, mock_popen):
|
|
409
|
+
mock_process = MagicMock()
|
|
410
|
+
mock_process.poll.return_value = None
|
|
411
|
+
mock_popen.return_value = mock_process
|
|
412
|
+
|
|
413
|
+
user_config = {
|
|
414
|
+
"server": {
|
|
415
|
+
"address": "10.0.0.1",
|
|
416
|
+
"port": 8002,
|
|
417
|
+
"username": "u",
|
|
418
|
+
"password": "p",
|
|
419
|
+
"tls": True,
|
|
420
|
+
"allowInsecure": True,
|
|
421
|
+
},
|
|
422
|
+
"local": {"socks_port": 1080, "http_port": 1087, "udp": True},
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
self.manager.start(user_config)
|
|
426
|
+
self.manager.stop()
|
|
427
|
+
|
|
428
|
+
mock_process.terminate.assert_called_once()
|
|
429
|
+
|
|
430
|
+
def test_is_running_false_initially(self):
|
|
431
|
+
assert self.manager.is_running() is False
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
435
|
+
|
|
436
|
+
```bash
|
|
437
|
+
cd /Users/bachi/jaylli/block-proxy/client
|
|
438
|
+
source .venv/bin/activate
|
|
439
|
+
python -m pytest tests/test_xray_manager.py -v
|
|
440
|
+
```
|
|
441
|
+
Expected: FAIL with `ModuleNotFoundError: No module named 'xray_manager'`
|
|
442
|
+
|
|
443
|
+
- [ ] **Step 3: Implement xray_manager.py**
|
|
444
|
+
|
|
445
|
+
Create `client/xray_manager.py`:
|
|
446
|
+
```python
|
|
447
|
+
import json
|
|
448
|
+
import os
|
|
449
|
+
import subprocess
|
|
450
|
+
import shutil
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class XrayManager:
|
|
454
|
+
def __init__(self, xray_path=None, config_dir=None):
|
|
455
|
+
if xray_path is None:
|
|
456
|
+
xray_path = shutil.which("xray") or "/usr/local/bin/xray"
|
|
457
|
+
if config_dir is None:
|
|
458
|
+
config_dir = os.path.expanduser(
|
|
459
|
+
"~/Library/Application Support/BlockProxyClient"
|
|
460
|
+
)
|
|
461
|
+
self.xray_path = xray_path
|
|
462
|
+
self.config_dir = config_dir
|
|
463
|
+
self.config_file = os.path.join(config_dir, "xray_config.json")
|
|
464
|
+
self.process = None
|
|
465
|
+
|
|
466
|
+
def generate_config(self, user_config):
|
|
467
|
+
server = user_config["server"]
|
|
468
|
+
local = user_config["local"]
|
|
469
|
+
|
|
470
|
+
xray_config = {
|
|
471
|
+
"inbounds": [
|
|
472
|
+
{
|
|
473
|
+
"tag": "socks-in",
|
|
474
|
+
"port": local["socks_port"],
|
|
475
|
+
"listen": "127.0.0.1",
|
|
476
|
+
"protocol": "socks",
|
|
477
|
+
"settings": {"udp": local["udp"]},
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
"tag": "http-in",
|
|
481
|
+
"port": local["http_port"],
|
|
482
|
+
"listen": "127.0.0.1",
|
|
483
|
+
"protocol": "http",
|
|
484
|
+
},
|
|
485
|
+
],
|
|
486
|
+
"outbounds": [self._build_outbound(server)],
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
os.makedirs(self.config_dir, exist_ok=True)
|
|
490
|
+
with open(self.config_file, "w") as f:
|
|
491
|
+
json.dump(xray_config, f, indent=2)
|
|
492
|
+
|
|
493
|
+
return self.config_file
|
|
494
|
+
|
|
495
|
+
def _build_outbound(self, server):
|
|
496
|
+
outbound = {
|
|
497
|
+
"protocol": "socks",
|
|
498
|
+
"settings": {
|
|
499
|
+
"servers": [self._build_server(server)],
|
|
500
|
+
},
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if server["tls"]:
|
|
504
|
+
outbound["streamSettings"] = {
|
|
505
|
+
"network": "tcp",
|
|
506
|
+
"security": "tls",
|
|
507
|
+
"tlsSettings": {
|
|
508
|
+
"allowInsecure": server["allowInsecure"],
|
|
509
|
+
"serverName": server["address"],
|
|
510
|
+
},
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return outbound
|
|
514
|
+
|
|
515
|
+
def _build_server(self, server):
|
|
516
|
+
entry = {
|
|
517
|
+
"address": server["address"],
|
|
518
|
+
"port": server["port"],
|
|
519
|
+
}
|
|
520
|
+
if server["username"] and server["password"]:
|
|
521
|
+
entry["users"] = [
|
|
522
|
+
{"user": server["username"], "pass": server["password"]}
|
|
523
|
+
]
|
|
524
|
+
return entry
|
|
525
|
+
|
|
526
|
+
def start(self, user_config):
|
|
527
|
+
if self.is_running():
|
|
528
|
+
self.stop()
|
|
529
|
+
|
|
530
|
+
config_path = self.generate_config(user_config)
|
|
531
|
+
self.process = subprocess.Popen(
|
|
532
|
+
[self.xray_path, "run", "-c", config_path],
|
|
533
|
+
stdout=subprocess.PIPE,
|
|
534
|
+
stderr=subprocess.PIPE,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
def stop(self):
|
|
538
|
+
if self.process is None:
|
|
539
|
+
return
|
|
540
|
+
self.process.terminate()
|
|
541
|
+
try:
|
|
542
|
+
self.process.wait(timeout=3)
|
|
543
|
+
except subprocess.TimeoutExpired:
|
|
544
|
+
self.process.kill()
|
|
545
|
+
self.process.wait()
|
|
546
|
+
self.process = None
|
|
547
|
+
|
|
548
|
+
def is_running(self):
|
|
549
|
+
if self.process is None:
|
|
550
|
+
return False
|
|
551
|
+
return self.process.poll() is None
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
555
|
+
|
|
556
|
+
```bash
|
|
557
|
+
cd /Users/bachi/jaylli/block-proxy/client
|
|
558
|
+
source .venv/bin/activate
|
|
559
|
+
python -m pytest tests/test_xray_manager.py -v
|
|
560
|
+
```
|
|
561
|
+
Expected: All 6 tests PASS
|
|
562
|
+
|
|
563
|
+
- [ ] **Step 5: Commit**
|
|
564
|
+
|
|
565
|
+
```bash
|
|
566
|
+
git add client/xray_manager.py client/tests/test_xray_manager.py
|
|
567
|
+
git commit -m "feat(client): add xray-core process manager module"
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
---
|
|
571
|
+
|
|
572
|
+
### Task 4: System Proxy Module
|
|
573
|
+
|
|
574
|
+
**Files:**
|
|
575
|
+
- Create: `client/tests/test_system_proxy.py`
|
|
576
|
+
- Create: `client/system_proxy.py`
|
|
577
|
+
|
|
578
|
+
- [ ] **Step 1: Write failing tests for system_proxy**
|
|
579
|
+
|
|
580
|
+
Create `client/tests/test_system_proxy.py`:
|
|
581
|
+
```python
|
|
582
|
+
import pytest
|
|
583
|
+
from unittest.mock import patch, call
|
|
584
|
+
from system_proxy import SystemProxy
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
class TestSystemProxy:
|
|
588
|
+
def setup_method(self):
|
|
589
|
+
self.proxy = SystemProxy()
|
|
590
|
+
|
|
591
|
+
@patch("system_proxy.SystemProxy._get_active_interfaces")
|
|
592
|
+
@patch("subprocess.run")
|
|
593
|
+
def test_enable_sets_all_proxy_types(self, mock_run, mock_interfaces):
|
|
594
|
+
mock_interfaces.return_value = ["Wi-Fi"]
|
|
595
|
+
mock_run.return_value = None
|
|
596
|
+
|
|
597
|
+
self.proxy.enable(socks_port=1080, http_port=1087)
|
|
598
|
+
|
|
599
|
+
expected_calls = [
|
|
600
|
+
call(["networksetup", "-setsocksfirewallproxy", "Wi-Fi", "127.0.0.1", "1080"], check=True),
|
|
601
|
+
call(["networksetup", "-setsocksfirewallproxystate", "Wi-Fi", "on"], check=True),
|
|
602
|
+
call(["networksetup", "-setwebproxy", "Wi-Fi", "127.0.0.1", "1087"], check=True),
|
|
603
|
+
call(["networksetup", "-setwebproxystate", "Wi-Fi", "on"], check=True),
|
|
604
|
+
call(["networksetup", "-setsecurewebproxy", "Wi-Fi", "127.0.0.1", "1087"], check=True),
|
|
605
|
+
call(["networksetup", "-setsecurewebproxystate", "Wi-Fi", "on"], check=True),
|
|
606
|
+
]
|
|
607
|
+
mock_run.assert_has_calls(expected_calls)
|
|
608
|
+
|
|
609
|
+
@patch("system_proxy.SystemProxy._get_active_interfaces")
|
|
610
|
+
@patch("subprocess.run")
|
|
611
|
+
def test_disable_clears_all_proxy_types(self, mock_run, mock_interfaces):
|
|
612
|
+
mock_interfaces.return_value = ["Wi-Fi"]
|
|
613
|
+
mock_run.return_value = None
|
|
614
|
+
|
|
615
|
+
self.proxy.disable()
|
|
616
|
+
|
|
617
|
+
expected_calls = [
|
|
618
|
+
call(["networksetup", "-setsocksfirewallproxystate", "Wi-Fi", "off"], check=True),
|
|
619
|
+
call(["networksetup", "-setwebproxystate", "Wi-Fi", "off"], check=True),
|
|
620
|
+
call(["networksetup", "-setsecurewebproxystate", "Wi-Fi", "off"], check=True),
|
|
621
|
+
]
|
|
622
|
+
mock_run.assert_has_calls(expected_calls)
|
|
623
|
+
|
|
624
|
+
@patch("system_proxy.SystemProxy._get_active_interfaces")
|
|
625
|
+
@patch("subprocess.run")
|
|
626
|
+
def test_enable_multiple_interfaces(self, mock_run, mock_interfaces):
|
|
627
|
+
mock_interfaces.return_value = ["Wi-Fi", "Ethernet"]
|
|
628
|
+
mock_run.return_value = None
|
|
629
|
+
|
|
630
|
+
self.proxy.enable(socks_port=1080, http_port=1087)
|
|
631
|
+
|
|
632
|
+
# 6 calls per interface × 2 interfaces = 12 calls
|
|
633
|
+
assert mock_run.call_count == 12
|
|
634
|
+
|
|
635
|
+
@patch("subprocess.run")
|
|
636
|
+
def test_get_active_interfaces(self, mock_run):
|
|
637
|
+
mock_run.return_value = type("Result", (), {
|
|
638
|
+
"stdout": "An asterisk (*) denotes that a network service is disabled.\nWi-Fi\n*Bluetooth PAN\nEthernet\n",
|
|
639
|
+
"returncode": 0,
|
|
640
|
+
})()
|
|
641
|
+
|
|
642
|
+
interfaces = self.proxy._get_active_interfaces()
|
|
643
|
+
|
|
644
|
+
assert "Wi-Fi" in interfaces
|
|
645
|
+
assert "Ethernet" in interfaces
|
|
646
|
+
assert "Bluetooth PAN" not in interfaces
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
650
|
+
|
|
651
|
+
```bash
|
|
652
|
+
cd /Users/bachi/jaylli/block-proxy/client
|
|
653
|
+
source .venv/bin/activate
|
|
654
|
+
python -m pytest tests/test_system_proxy.py -v
|
|
655
|
+
```
|
|
656
|
+
Expected: FAIL with `ModuleNotFoundError: No module named 'system_proxy'`
|
|
657
|
+
|
|
658
|
+
- [ ] **Step 3: Implement system_proxy.py**
|
|
659
|
+
|
|
660
|
+
Create `client/system_proxy.py`:
|
|
661
|
+
```python
|
|
662
|
+
import subprocess
|
|
663
|
+
import atexit
|
|
664
|
+
import signal
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
class SystemProxy:
|
|
668
|
+
def __init__(self):
|
|
669
|
+
self._enabled = False
|
|
670
|
+
self._interfaces = []
|
|
671
|
+
atexit.register(self._cleanup)
|
|
672
|
+
signal.signal(signal.SIGTERM, self._signal_handler)
|
|
673
|
+
|
|
674
|
+
def _signal_handler(self, signum, frame):
|
|
675
|
+
self._cleanup()
|
|
676
|
+
raise SystemExit(0)
|
|
677
|
+
|
|
678
|
+
def _cleanup(self):
|
|
679
|
+
if self._enabled:
|
|
680
|
+
self.disable()
|
|
681
|
+
|
|
682
|
+
def _get_active_interfaces(self):
|
|
683
|
+
result = subprocess.run(
|
|
684
|
+
["networksetup", "-listallnetworkservices"],
|
|
685
|
+
capture_output=True,
|
|
686
|
+
text=True,
|
|
687
|
+
)
|
|
688
|
+
interfaces = []
|
|
689
|
+
for line in result.stdout.strip().split("\n"):
|
|
690
|
+
if line.startswith("*") or line.startswith("An asterisk"):
|
|
691
|
+
continue
|
|
692
|
+
if line.strip():
|
|
693
|
+
interfaces.append(line.strip())
|
|
694
|
+
return interfaces
|
|
695
|
+
|
|
696
|
+
def enable(self, socks_port, http_port):
|
|
697
|
+
self._interfaces = self._get_active_interfaces()
|
|
698
|
+
for iface in self._interfaces:
|
|
699
|
+
subprocess.run(
|
|
700
|
+
["networksetup", "-setsocksfirewallproxy", iface, "127.0.0.1", str(socks_port)],
|
|
701
|
+
check=True,
|
|
702
|
+
)
|
|
703
|
+
subprocess.run(
|
|
704
|
+
["networksetup", "-setsocksfirewallproxystate", iface, "on"],
|
|
705
|
+
check=True,
|
|
706
|
+
)
|
|
707
|
+
subprocess.run(
|
|
708
|
+
["networksetup", "-setwebproxy", iface, "127.0.0.1", str(http_port)],
|
|
709
|
+
check=True,
|
|
710
|
+
)
|
|
711
|
+
subprocess.run(
|
|
712
|
+
["networksetup", "-setwebproxystate", iface, "on"],
|
|
713
|
+
check=True,
|
|
714
|
+
)
|
|
715
|
+
subprocess.run(
|
|
716
|
+
["networksetup", "-setsecurewebproxy", iface, "127.0.0.1", str(http_port)],
|
|
717
|
+
check=True,
|
|
718
|
+
)
|
|
719
|
+
subprocess.run(
|
|
720
|
+
["networksetup", "-setsecurewebproxystate", iface, "on"],
|
|
721
|
+
check=True,
|
|
722
|
+
)
|
|
723
|
+
self._enabled = True
|
|
724
|
+
|
|
725
|
+
def disable(self):
|
|
726
|
+
interfaces = self._interfaces or self._get_active_interfaces()
|
|
727
|
+
for iface in interfaces:
|
|
728
|
+
subprocess.run(
|
|
729
|
+
["networksetup", "-setsocksfirewallproxystate", iface, "off"],
|
|
730
|
+
check=True,
|
|
731
|
+
)
|
|
732
|
+
subprocess.run(
|
|
733
|
+
["networksetup", "-setwebproxystate", iface, "off"],
|
|
734
|
+
check=True,
|
|
735
|
+
)
|
|
736
|
+
subprocess.run(
|
|
737
|
+
["networksetup", "-setsecurewebproxystate", iface, "off"],
|
|
738
|
+
check=True,
|
|
739
|
+
)
|
|
740
|
+
self._enabled = False
|
|
741
|
+
self._interfaces = []
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
- [ ] **Step 4: Run tests to verify they pass**
|
|
745
|
+
|
|
746
|
+
```bash
|
|
747
|
+
cd /Users/bachi/jaylli/block-proxy/client
|
|
748
|
+
source .venv/bin/activate
|
|
749
|
+
python -m pytest tests/test_system_proxy.py -v
|
|
750
|
+
```
|
|
751
|
+
Expected: All 4 tests PASS
|
|
752
|
+
|
|
753
|
+
- [ ] **Step 5: Commit**
|
|
754
|
+
|
|
755
|
+
```bash
|
|
756
|
+
git add client/system_proxy.py client/tests/test_system_proxy.py
|
|
757
|
+
git commit -m "feat(client): add macOS system proxy management module"
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
---
|
|
761
|
+
|
|
762
|
+
### Task 5: Main Application (rumps status bar)
|
|
763
|
+
|
|
764
|
+
**Files:**
|
|
765
|
+
- Create: `client/app.py`
|
|
766
|
+
- Create: `client/main.py`
|
|
767
|
+
|
|
768
|
+
- [ ] **Step 1: Implement app.py**
|
|
769
|
+
|
|
770
|
+
Create `client/app.py`:
|
|
771
|
+
```python
|
|
772
|
+
import os
|
|
773
|
+
import threading
|
|
774
|
+
import rumps
|
|
775
|
+
from config import Config
|
|
776
|
+
from xray_manager import XrayManager
|
|
777
|
+
from system_proxy import SystemProxy
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
class BlockProxyClient(rumps.App):
|
|
781
|
+
def __init__(self):
|
|
782
|
+
super().__init__("BlockProxyClient", quit_button=None)
|
|
783
|
+
|
|
784
|
+
self.config = Config()
|
|
785
|
+
self.config.load()
|
|
786
|
+
self.xray = XrayManager()
|
|
787
|
+
self.sys_proxy = SystemProxy()
|
|
788
|
+
self.connected = False
|
|
789
|
+
|
|
790
|
+
self._build_menu()
|
|
791
|
+
self._update_icon()
|
|
792
|
+
self._start_health_check()
|
|
793
|
+
|
|
794
|
+
def _build_menu(self):
|
|
795
|
+
self.toggle_item = rumps.MenuItem("开启代理", callback=self.toggle_proxy)
|
|
796
|
+
self.config_item = rumps.MenuItem("节点配置...", callback=self.open_config)
|
|
797
|
+
|
|
798
|
+
self.global_item = rumps.MenuItem("全局代理", callback=self.set_global_mode)
|
|
799
|
+
self.manual_item = rumps.MenuItem("手动模式", callback=self.set_manual_mode)
|
|
800
|
+
|
|
801
|
+
self.quit_item = rumps.MenuItem("退出", callback=self.quit_app)
|
|
802
|
+
|
|
803
|
+
self.menu = [
|
|
804
|
+
self.toggle_item,
|
|
805
|
+
self.config_item,
|
|
806
|
+
None,
|
|
807
|
+
self.global_item,
|
|
808
|
+
self.manual_item,
|
|
809
|
+
None,
|
|
810
|
+
self.quit_item,
|
|
811
|
+
]
|
|
812
|
+
|
|
813
|
+
self._update_mode_menu()
|
|
814
|
+
|
|
815
|
+
def _update_mode_menu(self):
|
|
816
|
+
is_global = self.config.data["mode"] == "global"
|
|
817
|
+
self.global_item.state = 1 if is_global else 0
|
|
818
|
+
self.manual_item.state = 1 if not is_global else 0
|
|
819
|
+
|
|
820
|
+
def _update_icon(self):
|
|
821
|
+
icon_name = "icon.png" if self.connected else "icon_off.png"
|
|
822
|
+
icon_path = os.path.join(self._resource_dir(), icon_name)
|
|
823
|
+
if os.path.exists(icon_path):
|
|
824
|
+
self.icon = icon_path
|
|
825
|
+
|
|
826
|
+
def _resource_dir(self):
|
|
827
|
+
app_dir = os.path.dirname(os.path.abspath(__file__))
|
|
828
|
+
return os.path.join(app_dir, "resources")
|
|
829
|
+
|
|
830
|
+
def toggle_proxy(self, sender):
|
|
831
|
+
if self.connected:
|
|
832
|
+
self._disconnect()
|
|
833
|
+
else:
|
|
834
|
+
self._connect()
|
|
835
|
+
|
|
836
|
+
def _connect(self):
|
|
837
|
+
if not self.config.is_configured():
|
|
838
|
+
rumps.alert("请先配置节点信息")
|
|
839
|
+
return
|
|
840
|
+
|
|
841
|
+
self.xray.start(self.config.data)
|
|
842
|
+
if self.config.data["mode"] == "global":
|
|
843
|
+
self.sys_proxy.enable(
|
|
844
|
+
socks_port=self.config.data["local"]["socks_port"],
|
|
845
|
+
http_port=self.config.data["local"]["http_port"],
|
|
846
|
+
)
|
|
847
|
+
self.connected = True
|
|
848
|
+
self.toggle_item.title = "关闭代理"
|
|
849
|
+
self._update_icon()
|
|
850
|
+
|
|
851
|
+
def _disconnect(self):
|
|
852
|
+
self.sys_proxy.disable()
|
|
853
|
+
self.xray.stop()
|
|
854
|
+
self.connected = False
|
|
855
|
+
self.toggle_item.title = "开启代理"
|
|
856
|
+
self._update_icon()
|
|
857
|
+
|
|
858
|
+
def open_config(self, sender):
|
|
859
|
+
self._show_config_window()
|
|
860
|
+
|
|
861
|
+
def _show_config_window(self):
|
|
862
|
+
import tkinter as tk
|
|
863
|
+
from tkinter import ttk
|
|
864
|
+
|
|
865
|
+
def save_and_close():
|
|
866
|
+
self.config.data["server"]["address"] = entries["address"].get()
|
|
867
|
+
self.config.data["server"]["port"] = int(entries["port"].get())
|
|
868
|
+
self.config.data["server"]["username"] = entries["username"].get()
|
|
869
|
+
self.config.data["server"]["password"] = entries["password"].get()
|
|
870
|
+
self.config.data["server"]["tls"] = tls_var.get()
|
|
871
|
+
self.config.data["server"]["allowInsecure"] = insecure_var.get() == "true"
|
|
872
|
+
self.config.data["local"]["socks_port"] = int(entries["socks_port"].get())
|
|
873
|
+
self.config.data["local"]["http_port"] = int(entries["http_port"].get())
|
|
874
|
+
self.config.data["local"]["udp"] = udp_var.get()
|
|
875
|
+
self.config.save()
|
|
876
|
+
root.destroy()
|
|
877
|
+
|
|
878
|
+
root = tk.Tk()
|
|
879
|
+
root.title("节点配置")
|
|
880
|
+
root.geometry("400x380")
|
|
881
|
+
root.resizable(False, False)
|
|
882
|
+
|
|
883
|
+
frame = ttk.Frame(root, padding=20)
|
|
884
|
+
frame.pack(fill="both", expand=True)
|
|
885
|
+
|
|
886
|
+
entries = {}
|
|
887
|
+
fields = [
|
|
888
|
+
("address", "地址:", self.config.data["server"]["address"]),
|
|
889
|
+
("port", "端口:", str(self.config.data["server"]["port"])),
|
|
890
|
+
("username", "用户名:", self.config.data["server"]["username"]),
|
|
891
|
+
("password", "密码:", self.config.data["server"]["password"]),
|
|
892
|
+
("socks_port", "本地SOCKS端口:", str(self.config.data["local"]["socks_port"])),
|
|
893
|
+
("http_port", "本地HTTP端口:", str(self.config.data["local"]["http_port"])),
|
|
894
|
+
]
|
|
895
|
+
|
|
896
|
+
for i, (key, label, default) in enumerate(fields):
|
|
897
|
+
ttk.Label(frame, text=label).grid(row=i, column=0, sticky="w", pady=4)
|
|
898
|
+
entry = ttk.Entry(frame, width=30)
|
|
899
|
+
if key == "password":
|
|
900
|
+
entry.config(show="*")
|
|
901
|
+
entry.insert(0, default)
|
|
902
|
+
entry.grid(row=i, column=1, sticky="w", pady=4)
|
|
903
|
+
entries[key] = entry
|
|
904
|
+
|
|
905
|
+
row = len(fields)
|
|
906
|
+
|
|
907
|
+
tls_var = tk.BooleanVar(value=self.config.data["server"]["tls"])
|
|
908
|
+
ttk.Checkbutton(frame, text="启用 TLS", variable=tls_var).grid(
|
|
909
|
+
row=row, column=0, columnspan=2, sticky="w", pady=4
|
|
910
|
+
)
|
|
911
|
+
row += 1
|
|
912
|
+
|
|
913
|
+
ttk.Label(frame, text="allowInsecure:").grid(row=row, column=0, sticky="w", pady=4)
|
|
914
|
+
insecure_var = tk.StringVar(
|
|
915
|
+
value="true" if self.config.data["server"]["allowInsecure"] else "false"
|
|
916
|
+
)
|
|
917
|
+
insecure_combo = ttk.Combobox(
|
|
918
|
+
frame, textvariable=insecure_var, values=["true", "false"], state="readonly", width=10
|
|
919
|
+
)
|
|
920
|
+
insecure_combo.grid(row=row, column=1, sticky="w", pady=4)
|
|
921
|
+
row += 1
|
|
922
|
+
|
|
923
|
+
udp_var = tk.BooleanVar(value=self.config.data["local"]["udp"])
|
|
924
|
+
ttk.Checkbutton(frame, text="启用 UDP", variable=udp_var).grid(
|
|
925
|
+
row=row, column=0, columnspan=2, sticky="w", pady=4
|
|
926
|
+
)
|
|
927
|
+
row += 1
|
|
928
|
+
|
|
929
|
+
ttk.Button(frame, text="保存", command=save_and_close).grid(
|
|
930
|
+
row=row, column=0, columnspan=2, pady=15
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
root.lift()
|
|
934
|
+
root.attributes("-topmost", True)
|
|
935
|
+
root.mainloop()
|
|
936
|
+
|
|
937
|
+
def set_global_mode(self, sender):
|
|
938
|
+
self.config.data["mode"] = "global"
|
|
939
|
+
self.config.save()
|
|
940
|
+
self._update_mode_menu()
|
|
941
|
+
if self.connected:
|
|
942
|
+
self.sys_proxy.enable(
|
|
943
|
+
socks_port=self.config.data["local"]["socks_port"],
|
|
944
|
+
http_port=self.config.data["local"]["http_port"],
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
def set_manual_mode(self, sender):
|
|
948
|
+
self.config.data["mode"] = "manual"
|
|
949
|
+
self.config.save()
|
|
950
|
+
self._update_mode_menu()
|
|
951
|
+
if self.connected:
|
|
952
|
+
self.sys_proxy.disable()
|
|
953
|
+
|
|
954
|
+
def quit_app(self, sender):
|
|
955
|
+
if self.connected:
|
|
956
|
+
self._disconnect()
|
|
957
|
+
rumps.quit_application()
|
|
958
|
+
|
|
959
|
+
def _start_health_check(self):
|
|
960
|
+
def check():
|
|
961
|
+
while True:
|
|
962
|
+
import time
|
|
963
|
+
time.sleep(5)
|
|
964
|
+
if self.connected and not self.xray.is_running():
|
|
965
|
+
self._disconnect()
|
|
966
|
+
rumps.notification(
|
|
967
|
+
"BlockProxyClient",
|
|
968
|
+
"代理已断开",
|
|
969
|
+
"xray-core 进程意外退出",
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
t = threading.Thread(target=check, daemon=True)
|
|
973
|
+
t.start()
|
|
974
|
+
```
|
|
975
|
+
|
|
976
|
+
- [ ] **Step 2: Implement main.py**
|
|
977
|
+
|
|
978
|
+
Create `client/main.py`:
|
|
979
|
+
```python
|
|
980
|
+
from app import BlockProxyClient
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def main():
|
|
984
|
+
client = BlockProxyClient()
|
|
985
|
+
client.run()
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
if __name__ == "__main__":
|
|
989
|
+
main()
|
|
990
|
+
```
|
|
991
|
+
|
|
992
|
+
- [ ] **Step 3: Manual test - verify app launches**
|
|
993
|
+
|
|
994
|
+
```bash
|
|
995
|
+
cd /Users/bachi/jaylli/block-proxy/client
|
|
996
|
+
source .venv/bin/activate
|
|
997
|
+
python main.py
|
|
998
|
+
```
|
|
999
|
+
Expected: Status bar icon appears, menu opens with all items, clicking "节点配置..." opens tkinter window.
|
|
1000
|
+
|
|
1001
|
+
Press "退出" to close.
|
|
1002
|
+
|
|
1003
|
+
- [ ] **Step 4: Commit**
|
|
1004
|
+
|
|
1005
|
+
```bash
|
|
1006
|
+
git add client/app.py client/main.py
|
|
1007
|
+
git commit -m "feat(client): add main application with status bar menu and config window"
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
---
|
|
1011
|
+
|
|
1012
|
+
### Task 6: Resource Files and Icons
|
|
1013
|
+
|
|
1014
|
+
**Files:**
|
|
1015
|
+
- Create: `client/resources/icon.png`
|
|
1016
|
+
- Create: `client/resources/icon_off.png`
|
|
1017
|
+
|
|
1018
|
+
- [ ] **Step 1: Generate placeholder icons**
|
|
1019
|
+
|
|
1020
|
+
```bash
|
|
1021
|
+
cd /Users/bachi/jaylli/block-proxy/client
|
|
1022
|
+
source .venv/bin/activate
|
|
1023
|
+
pip install Pillow
|
|
1024
|
+
python -c "
|
|
1025
|
+
from PIL import Image, ImageDraw
|
|
1026
|
+
# Connected icon - solid dark circle
|
|
1027
|
+
img = Image.new('RGBA', (22, 22), (0, 0, 0, 0))
|
|
1028
|
+
draw = ImageDraw.Draw(img)
|
|
1029
|
+
draw.ellipse([4, 4, 18, 18], fill=(0, 0, 0, 255))
|
|
1030
|
+
img.save('resources/icon.png')
|
|
1031
|
+
|
|
1032
|
+
# Disconnected icon - hollow circle
|
|
1033
|
+
img2 = Image.new('RGBA', (22, 22), (0, 0, 0, 0))
|
|
1034
|
+
draw2 = ImageDraw.Draw(img2)
|
|
1035
|
+
draw2.ellipse([4, 4, 18, 18], outline=(128, 128, 128, 255), width=2)
|
|
1036
|
+
img2.save('resources/icon_off.png')
|
|
1037
|
+
print('Icons created')
|
|
1038
|
+
"
|
|
1039
|
+
```
|
|
1040
|
+
|
|
1041
|
+
- [ ] **Step 2: Verify icons exist**
|
|
1042
|
+
|
|
1043
|
+
```bash
|
|
1044
|
+
ls -la /Users/bachi/jaylli/block-proxy/client/resources/icon*.png
|
|
1045
|
+
```
|
|
1046
|
+
Expected: Both `icon.png` and `icon_off.png` exist, ~22x22 PNG files.
|
|
1047
|
+
|
|
1048
|
+
- [ ] **Step 3: Commit**
|
|
1049
|
+
|
|
1050
|
+
```bash
|
|
1051
|
+
git add client/resources/icon.png client/resources/icon_off.png
|
|
1052
|
+
git commit -m "feat(client): add status bar icons"
|
|
1053
|
+
```
|
|
1054
|
+
|
|
1055
|
+
---
|
|
1056
|
+
|
|
1057
|
+
### Task 7: py2app Packaging Setup
|
|
1058
|
+
|
|
1059
|
+
**Files:**
|
|
1060
|
+
- Create: `client/setup.py`
|
|
1061
|
+
|
|
1062
|
+
- [ ] **Step 1: Create setup.py**
|
|
1063
|
+
|
|
1064
|
+
Create `client/setup.py`:
|
|
1065
|
+
```python
|
|
1066
|
+
from setuptools import setup
|
|
1067
|
+
|
|
1068
|
+
APP = ["main.py"]
|
|
1069
|
+
DATA_FILES = [
|
|
1070
|
+
("resources", ["resources/icon.png", "resources/icon_off.png"]),
|
|
1071
|
+
]
|
|
1072
|
+
OPTIONS = {
|
|
1073
|
+
"argv_emulation": False,
|
|
1074
|
+
"iconfile": "resources/icon.png",
|
|
1075
|
+
"plist": {
|
|
1076
|
+
"LSUIElement": True,
|
|
1077
|
+
"CFBundleName": "BlockProxyClient",
|
|
1078
|
+
"CFBundleIdentifier": "com.jaylli.blockproxyclient",
|
|
1079
|
+
"CFBundleVersion": "1.0.0",
|
|
1080
|
+
"CFBundleShortVersionString": "1.0.0",
|
|
1081
|
+
},
|
|
1082
|
+
"packages": ["rumps"],
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
setup(
|
|
1086
|
+
name="BlockProxyClient",
|
|
1087
|
+
app=APP,
|
|
1088
|
+
data_files=DATA_FILES,
|
|
1089
|
+
options={"py2app": OPTIONS},
|
|
1090
|
+
setup_requires=["py2app"],
|
|
1091
|
+
)
|
|
1092
|
+
```
|
|
1093
|
+
|
|
1094
|
+
- [ ] **Step 2: Test build (development mode)**
|
|
1095
|
+
|
|
1096
|
+
```bash
|
|
1097
|
+
cd /Users/bachi/jaylli/block-proxy/client
|
|
1098
|
+
source .venv/bin/activate
|
|
1099
|
+
python setup.py py2app -A
|
|
1100
|
+
```
|
|
1101
|
+
Expected: Creates `dist/BlockProxyClient.app` (alias mode, for development testing).
|
|
1102
|
+
|
|
1103
|
+
- [ ] **Step 3: Verify .app launches**
|
|
1104
|
+
|
|
1105
|
+
```bash
|
|
1106
|
+
open dist/BlockProxyClient.app
|
|
1107
|
+
```
|
|
1108
|
+
Expected: Status bar icon appears. Close via menu "退出".
|
|
1109
|
+
|
|
1110
|
+
- [ ] **Step 4: Commit**
|
|
1111
|
+
|
|
1112
|
+
```bash
|
|
1113
|
+
git add client/setup.py
|
|
1114
|
+
git commit -m "feat(client): add py2app packaging configuration"
|
|
1115
|
+
```
|
|
1116
|
+
|
|
1117
|
+
---
|
|
1118
|
+
|
|
1119
|
+
### Task 8: Download xray-core and Integration Test
|
|
1120
|
+
|
|
1121
|
+
**Files:**
|
|
1122
|
+
- Create: `client/scripts/download_xray.sh`
|
|
1123
|
+
|
|
1124
|
+
- [ ] **Step 1: Create xray-core download script**
|
|
1125
|
+
|
|
1126
|
+
Create `client/scripts/download_xray.sh`:
|
|
1127
|
+
```bash
|
|
1128
|
+
#!/bin/bash
|
|
1129
|
+
set -e
|
|
1130
|
+
|
|
1131
|
+
XRAY_VERSION="v25.5.16"
|
|
1132
|
+
PLATFORM="macos"
|
|
1133
|
+
ARCH=$(uname -m)
|
|
1134
|
+
|
|
1135
|
+
if [ "$ARCH" = "arm64" ]; then
|
|
1136
|
+
FILENAME="Xray-macos-arm64-v8a.zip"
|
|
1137
|
+
elif [ "$ARCH" = "x86_64" ]; then
|
|
1138
|
+
FILENAME="Xray-macos-64.zip"
|
|
1139
|
+
else
|
|
1140
|
+
echo "Unsupported architecture: $ARCH"
|
|
1141
|
+
exit 1
|
|
1142
|
+
fi
|
|
1143
|
+
|
|
1144
|
+
URL="https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}/${FILENAME}"
|
|
1145
|
+
DEST_DIR="$(dirname "$0")/../resources"
|
|
1146
|
+
|
|
1147
|
+
echo "Downloading xray-core ${XRAY_VERSION} for ${ARCH}..."
|
|
1148
|
+
curl -L -o /tmp/xray.zip "$URL"
|
|
1149
|
+
|
|
1150
|
+
echo "Extracting..."
|
|
1151
|
+
unzip -o /tmp/xray.zip xray -d "$DEST_DIR"
|
|
1152
|
+
chmod +x "$DEST_DIR/xray"
|
|
1153
|
+
rm /tmp/xray.zip
|
|
1154
|
+
|
|
1155
|
+
echo "Done: $DEST_DIR/xray"
|
|
1156
|
+
"$DEST_DIR/xray" version
|
|
1157
|
+
```
|
|
1158
|
+
|
|
1159
|
+
- [ ] **Step 2: Download xray-core**
|
|
1160
|
+
|
|
1161
|
+
```bash
|
|
1162
|
+
cd /Users/bachi/jaylli/block-proxy/client
|
|
1163
|
+
mkdir -p scripts
|
|
1164
|
+
chmod +x scripts/download_xray.sh
|
|
1165
|
+
bash scripts/download_xray.sh
|
|
1166
|
+
```
|
|
1167
|
+
Expected: xray binary downloaded to `resources/xray` (or `resources/xray-core`), version printed.
|
|
1168
|
+
|
|
1169
|
+
- [ ] **Step 3: Add xray binary to .gitignore**
|
|
1170
|
+
|
|
1171
|
+
Append to `client/.gitignore`:
|
|
1172
|
+
```
|
|
1173
|
+
.venv/
|
|
1174
|
+
dist/
|
|
1175
|
+
build/
|
|
1176
|
+
*.egg-info/
|
|
1177
|
+
resources/xray
|
|
1178
|
+
resources/xray-core
|
|
1179
|
+
__pycache__/
|
|
1180
|
+
```
|
|
1181
|
+
|
|
1182
|
+
- [ ] **Step 4: Integration test - connect to server**
|
|
1183
|
+
|
|
1184
|
+
Manually test:
|
|
1185
|
+
1. Start the app: `python main.py`
|
|
1186
|
+
2. Click "节点配置..." → fill in your server details → Save
|
|
1187
|
+
3. Click "开启代理"
|
|
1188
|
+
4. Verify: `curl --proxy socks5://127.0.0.1:1080 https://httpbin.org/ip`
|
|
1189
|
+
5. Verify: `curl --proxy http://127.0.0.1:1087 https://httpbin.org/ip`
|
|
1190
|
+
6. Click "关闭代理"
|
|
1191
|
+
|
|
1192
|
+
Expected: Both curl commands return your server's IP when proxy is on, and fail/return local IP when off.
|
|
1193
|
+
|
|
1194
|
+
- [ ] **Step 5: Commit**
|
|
1195
|
+
|
|
1196
|
+
```bash
|
|
1197
|
+
git add client/scripts/download_xray.sh client/.gitignore
|
|
1198
|
+
git commit -m "feat(client): add xray-core download script and gitignore"
|
|
1199
|
+
```
|
|
1200
|
+
|
|
1201
|
+
---
|
|
1202
|
+
|
|
1203
|
+
### Task 9: Final Production Build
|
|
1204
|
+
|
|
1205
|
+
**Files:**
|
|
1206
|
+
- Modify: `client/setup.py` (add xray-core to DATA_FILES)
|
|
1207
|
+
|
|
1208
|
+
- [ ] **Step 1: Update setup.py to include xray binary**
|
|
1209
|
+
|
|
1210
|
+
In `client/setup.py`, update DATA_FILES to:
|
|
1211
|
+
```python
|
|
1212
|
+
DATA_FILES = [
|
|
1213
|
+
("resources", ["resources/icon.png", "resources/icon_off.png", "resources/xray"]),
|
|
1214
|
+
]
|
|
1215
|
+
```
|
|
1216
|
+
|
|
1217
|
+
- [ ] **Step 2: Build production .app**
|
|
1218
|
+
|
|
1219
|
+
```bash
|
|
1220
|
+
cd /Users/bachi/jaylli/block-proxy/client
|
|
1221
|
+
source .venv/bin/activate
|
|
1222
|
+
rm -rf dist build
|
|
1223
|
+
python setup.py py2app
|
|
1224
|
+
```
|
|
1225
|
+
Expected: Creates standalone `dist/BlockProxyClient.app` with all dependencies bundled.
|
|
1226
|
+
|
|
1227
|
+
- [ ] **Step 3: Update xray_manager to find bundled binary**
|
|
1228
|
+
|
|
1229
|
+
In `client/xray_manager.py`, update the `__init__` method's xray_path default detection:
|
|
1230
|
+
```python
|
|
1231
|
+
def __init__(self, xray_path=None, config_dir=None):
|
|
1232
|
+
if xray_path is None:
|
|
1233
|
+
# Check for bundled binary in .app Resources
|
|
1234
|
+
bundle_path = os.path.join(
|
|
1235
|
+
os.path.dirname(os.path.abspath(__file__)),
|
|
1236
|
+
"resources",
|
|
1237
|
+
"xray",
|
|
1238
|
+
)
|
|
1239
|
+
if os.path.exists(bundle_path):
|
|
1240
|
+
xray_path = bundle_path
|
|
1241
|
+
else:
|
|
1242
|
+
xray_path = shutil.which("xray") or "/usr/local/bin/xray"
|
|
1243
|
+
if config_dir is None:
|
|
1244
|
+
config_dir = os.path.expanduser(
|
|
1245
|
+
"~/Library/Application Support/BlockProxyClient"
|
|
1246
|
+
)
|
|
1247
|
+
self.xray_path = xray_path
|
|
1248
|
+
self.config_dir = config_dir
|
|
1249
|
+
self.config_file = os.path.join(config_dir, "xray_config.json")
|
|
1250
|
+
self.process = None
|
|
1251
|
+
```
|
|
1252
|
+
|
|
1253
|
+
- [ ] **Step 4: Verify production build launches and connects**
|
|
1254
|
+
|
|
1255
|
+
```bash
|
|
1256
|
+
open dist/BlockProxyClient.app
|
|
1257
|
+
```
|
|
1258
|
+
Expected: App runs from .app bundle, proxy connect/disconnect works.
|
|
1259
|
+
|
|
1260
|
+
- [ ] **Step 5: Run all unit tests one final time**
|
|
1261
|
+
|
|
1262
|
+
```bash
|
|
1263
|
+
cd /Users/bachi/jaylli/block-proxy/client
|
|
1264
|
+
source .venv/bin/activate
|
|
1265
|
+
python -m pytest tests/ -v
|
|
1266
|
+
```
|
|
1267
|
+
Expected: All tests PASS.
|
|
1268
|
+
|
|
1269
|
+
- [ ] **Step 6: Commit**
|
|
1270
|
+
|
|
1271
|
+
```bash
|
|
1272
|
+
git add client/setup.py client/xray_manager.py
|
|
1273
|
+
git commit -m "feat(client): finalize production build with bundled xray-core"
|
|
1274
|
+
```
|